# Building Qt Apps

> ALWAYS LOAD THIS SKILL WHEN WORKING WITH PYSIDE6, QT, OR DESKTOP GUI CODE. Do not write PySide6 or Qt code directly — use this skill first. PySide6 desktop apps: Manager→Service→Wrapper architecture, QML View+ViewModel (MVVM) integration, qasync integration, signals, system tray, testing.

- Skill: `quick-brown-foxxx/building-qt-apps` (Agent Skill)
- Install (CLI): `npx skillmds@latest add quick-brown-foxxx/building-qt-apps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/quick-brown-foxxx/building-qt-apps/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: quick-brown-foxxx (https://skillmd.com/u/quick-brown-foxxx)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/quick-brown-foxxx/building-qt-apps

---


# Building Qt Apps

## Prerequisites

This skill extends myai's `architecting-changes` and `engineering-principles`. Load those first. `using-my-skills` and `engineering-principles` are assumed already loaded via myai bootstrap.

For the general architecture principles and engineering philosophy, see myai's `architecting-changes` and `engineering-principles`. This skill covers only Python-specific Qt patterns: PySide6 + qasync, Manager→Service→Wrapper layering, signals/slots, QML integration, XDG desktop portals, and Qt-specific testing with pytest-qt.

Qt apps use PySide6 with qasync for async integration. Architecture follows Manager → Service → Wrapper layering. Never block the event loop.

---

## Why PySide6

- LGPL license (no additional restrictions)
- No extra system dependencies (ships with wheels)
- Same API as PyQt6, but freely redistributable

---

## Architecture: Manager → Service → Wrapper

For dependency wiring patterns (composition root), see `building-multi-ui-apps` skill.

```
UI Layer (MainWindow, Dialogs, TrayIcon)
    |  Qt signals/slots
    v
Manager Layer (AudioManager, TranscriptionManager)
    |  orchestrates, emits signals
    v
Service Layer (TranscriptionService, RecordingService)
    |  async operations
    v
Wrapper Layer (WhisperWrapper, SoundcardWrapper)
    |  typed interfaces to third-party libs
    v
Third-Party Libraries
```

### Manager Pattern

Managers coordinate operations and emit Qt signals:

```python
class TranscriptionManager(QObject):
    transcription_finished = Signal(str)
    transcription_error = Signal(str)
    model_changed = Signal(str)

    def __init__(self, settings: Settings) -> None:
        super().__init__()
        self._service: TranscriptionService | None = None
        self._bridge = QAsyncSignalBridge()

    def transcribe(self, audio_data: np.ndarray) -> bool:
        if not self._service:
            self.transcription_error.emit("Service not initialized")
            return False

        self._bridge.run_async(
            self._service.transcribe(audio_data),
            on_success=self._on_finished,
            on_error=self._on_error,
        )
        return True

    def _on_finished(self, text: str) -> None:
        self.transcription_finished.emit(text)

    def _on_error(self, error: str) -> None:
        self.transcription_error.emit(error)
```

### Wrapper Pattern

Typed wrappers isolate untyped third-party APIs:

```python
class WhisperModelWrapper:
    """Typed wrapper for faster-whisper."""

    def __init__(self, model_size: str, device: str = "auto") -> None:
        from faster_whisper import WhisperModel as _WhisperModel
        self._model = _WhisperModel(model_size, device=device)

    def transcribe(self, audio: np.ndarray, language: str | None = None) -> TranscriptionResult:
        segments_gen, info = self._model.transcribe(audio, language=language)
        return TranscriptionResult(
            text="".join(s.text for s in segments_gen),
            language=str(info.language),
        )
```

---

## Async Integration with qasync (over QtAsyncio, which is still in technical preview)

### Setup

This startup shape is fine for a GUI-only app. If the app also supports CLI commands, do not switch on `len(sys.argv) > 1`; use the tiny top-level router pattern from `building-multi-ui-apps`, and let the Qt startup stay in the GUI entry point only.

```python
import asyncio
import signal
import qasync
from PySide6.QtWidgets import QApplication

def main() -> int:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    signal.signal(signal.SIGINT, signal.SIG_DFL)  # Make Ctrl+C work (Qt blocks it)
    with loop:
        window = MainWindow()
        window.show()
        loop.run_forever()
    return 0
```

### QAsyncSignalBridge

Bridge async coroutines to Qt signals:

```python
class QAsyncSignalBridge(QObject):
    finished = Signal(object)
    error = Signal(str)

    def run_async(
        self,
        coro: Coroutine[object, None, T],
        on_success: Callable[[T], None] | None = None,
        on_error: Callable[[str], None] | None = None,
    ) -> None:
        async def _wrapped() -> None:
            try:
                result = await coro
                if on_success:
                    on_success(result)
                else:
                    self.finished.emit(result)
            except Exception as e:
                if on_error:
                    on_error(str(e))
                else:
                    self.error.emit(str(e))

        loop = asyncio.get_running_loop()
        self._task = loop.create_task(_wrapped())
```

### ThreadPoolExecutor for Blocking Libraries

When a library only provides sync API:

```python
class AsyncRecorder(QObject):
    recording_completed = Signal(np.ndarray)

    def __init__(self) -> None:
        super().__init__()
        self._executor = ThreadPoolExecutor(max_workers=1)

    async def start_recording(self) -> None:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(self._executor, self._sync_record)
        self.recording_completed.emit(result)
```

---

## Key Rules

1. **PySide6** (LGPL, no system deps) over PyQt
2. **Never block event loop**: no `subprocess.run()`, no `time.sleep()`, no sync HTTP
3. **qasync** bridges asyncio and Qt event loops
4. **ThreadPoolExecutor** wraps blocking third-party APIs
5. **Typed wrappers** around untyped libraries, enforced via ruff `banned-api`
6. **Signals at class level**, not in `__init__`
7. **camelCase for Qt event handlers** (ignore ruff N802), **snake_case for our slots**

---

## Ctrl+C and Shutdown

Qt's event loop blocks Python signal handling, making Ctrl+C appear to do nothing. Fix: `signal.signal(signal.SIGINT, signal.SIG_DFL)` before `loop.run_forever()` — lets the OS handle SIGINT directly (shown in the setup example above).

**If the app needs cleanup on Ctrl+C** (save state, release locks, stop recordings), use a handler that calls `QApplication.quit()` instead of `SIG_DFL`, so Qt's shutdown sequence runs:

```python
def _sigint_handler(*_args: object) -> None:
    QApplication.quit()

signal.signal(signal.SIGINT, _sigint_handler)

# Timer lets Python process the signal between Qt events
timer = QTimer()
timer.start(200)
timer.timeout.connect(lambda: None)
```

For subprocess shutdown patterns, see `setting-up-python-projects` skill.

---

## Signal/Slot Conventions

- Define signals at class level (not in `__init__`)
- Connect signals in the component that owns the relationship
- Use typed signals: `Signal(str)`, `Signal(float)`. Use `Signal(object)` only when PySide6 lacks generic signal support — add `# PySide6 limitation: no generic signals` comment

```python
class AudioManager(QObject):
    volume_changed = Signal(float)
    recording_completed = Signal(np.ndarray)
    recording_failed = Signal(str)

    def __init__(self) -> None:
        super().__init__()
        self._recorder = AsyncRecorder()
        self._recorder.recording_completed.connect(self.recording_completed)
```

---

## Naming Convention Exception

Qt event handlers use `camelCase` per Qt convention:

```toml
[tool.ruff.lint]
ignore = ["N802"]  # Qt event handlers use camelCase
```

```python
class CustomWidget(QWidget):
    def mousePressEvent(self, event: QMouseEvent) -> None:  # Qt convention
        ...

    def on_button_clicked(self) -> None:  # Our slots use snake_case
        ...
```

---

## Declarative Label → Callback Pattern

Whenever bootstrapping a fixed set of labeled actions — tray menus, button bars, context menus, toolbar items — avoid imperative `addAction`/`addButton` chains. Instead, declare all entries as data at the top of the setup method (where `self` is in scope for type-safe bound-method references) and drive the construction with a generic loop at the bottom.

`"SEPARATOR"` is a `Literal` sentinel: basedpyright rejects any other string in that position, so both the sentinel and the callbacks are fully type-checked.

```python
from typing import Callable, Final, Literal

_SEPARATOR: Final = "SEPARATOR"
_Entry = tuple[str, Callable[[], None]] | Literal["SEPARATOR"]

class ApplicationTrayIcon(QSystemTrayIcon):
    def __init__(self) -> None:
        super().__init__()
        self.setIcon(QIcon("icon.png"))
        self._setup_menu()

    def _setup_menu(self) -> None:
        entries: list[_Entry] = [
            ("Settings", self._open_settings),
            _SEPARATOR,
            ("Quit", QApplication.quit),
        ]

        menu = QMenu()
        for entry in entries:
            if entry is _SEPARATOR:
                menu.addSeparator()
            else:
                label, cb = entry
                menu.addAction(label, cb)
        self.setContextMenu(menu)

    def _open_settings(self) -> None: ...
```

`entries` is the single place to add, remove, or reorder items. The loop is generic boilerplate that never changes. Mistyping `self._poen_settings` is caught by basedpyright at check time — no runtime surprises. The same pattern applies to button bars, context menus, or any other label → callback mapping.

---

## Single Instance Enforcement

```python
class LockManager:
    def __init__(self, lock_path: Path) -> None:
        self._lock_path = lock_path

    def acquire(self) -> Result[None, str]:
        if self._lock_path.exists():
            pid = int(self._lock_path.read_text())
            if self._is_process_running(pid):
                return Err(f"Another instance running (PID {pid})")
            # Stale lock file
        self._lock_path.write_text(str(os.getpid()))
        return Ok(None)

    def release(self) -> None:
        self._lock_path.unlink(missing_ok=True)
```

---

## Keyboard Shortcuts

Customizable via TOML config:

```python
class ActionID(enum.Enum):
    NEW_PROFILE = "new_profile"
    START_PROFILE = "start_profile"

@dataclass
class ActionShortcut:
    id: str
    label: str
    default_key: str

DEFAULT_SHORTCUTS = (
    ActionShortcut(ActionID.NEW_PROFILE.value, "New Profile", "Ctrl+N"),
    ActionShortcut(ActionID.START_PROFILE.value, "Start Profile", "Return"),
)
```

User overrides stored in `~/.config/appname/shortcuts.toml`.

---

## Settings Management

Type-safe QSettings wrapper:

```python
class Settings:
    def __init__(self) -> None:
        self._settings = QSettings(APP_NAME, APP_NAME)
        self._init_defaults()

    def get_str(self, key: str, default: str = "") -> str:
        value = self._settings.value(key, default)
        return str(value) if value is not None else default

    def get_int(self, key: str, default: int = 0) -> int:
        value = self._settings.value(key, default)
        return int(value) if value is not None else default

    def set(self, key: str, value: str | int | bool) -> None:
        self._settings.setValue(key, value)
```

---

## QML Integration: View + ViewModel (MVVM)

For QML-based UIs, treat **QML as a pure, dumb view** and keep all logic in Python so it is unit-testable without launching QML. Use the **View–ViewModel (MVVM)** split:

- **View** = QML only. No business logic in QML: no real work in `function` blocks, no navigation decisions, no rules in bindings. It renders state and emits "user intent" (calls VM slots).
- **ViewModel** = a `QObject` exposed to QML. Pure state + commands; no Qt widgets. This is the single thing QML binds to, and the only testable-both-ways unit (pytest-qt).
- **Model** = pure data/logic below the VM. Can be plain Python with no Qt dependency (best testability).

Properties replace MVVM data-binding; `@Slot` methods act as the "commands."

### The Single Bridge

There is exactly one bridge between QML and Qt: a `QObject` handed to the engine context. Expose it once at startup (composition root) — do not scatter `setContextProperty` calls:

```python
from PySide6.QtCore import QObject, Property, Signal, Slot

class PageViewModel(QObject):
    pageChanged = Signal(str)

    @Property(str)
    def currentPage(self) -> str: ...          # read-only state
    @currentPage.setter
    def currentPage(self, value: str) -> None: ...

    @Slot(result=str)
    def title(self) -> str: ...

    @Slot(str)                                  # user intent -> logic
    def navigate(self, page: str) -> None: ...

# composition root
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("vm", page_vm)
engine.load("Main.qml")
```

**Keep the VM QML-unaware.** It must not touch QML internals — expose typed slots/properties/signals and let QML translate them into UI.

### Multi-Page / Shell (per-page VMs, no mega-seam)

For a multi-page app give **each page its own ViewModel** plus a **root/shell ViewModel** for app-level concerns only (current page, shared state, navigation to a VM or an event bus). Build all VMs in the composition root with **only their own dependencies** so each is constructible in isolation with fakes:

```python
root = RootViewModel(settings, api)
root.pageA = PageAVM(settings, api)      # each page gets ONLY its deps
root.pageB = PageBVM(settings, repo)
```

Have pages receive **exactly their own VM** (not the whole `root` object) and keep the shell a thin navigator (`StackView`/`Loader`) that only reacts to the root VM's current-page signal. Do not:

- Put every property/slot on one giant `app` object.
- Pass the whole root VM into every page.
- Put logic in QML.
- Let the shell know each page's internals.

For lists/collections, expose a `QAbstractListModel`/`QAbstractItemModel` to QML — QML views (`ListView`, `Repeater`) are driven by models, not by throwing Python iterables around.

---

## Testing Qt Components

Use `pytest-qt`:

```python
def test_main_window_creates(qtbot: QtBot) -> None:
    window = MainWindow()
    qtbot.addWidget(window)
    assert window.isVisible() is False  # Not shown until .show()

def test_button_click(qtbot: QtBot) -> None:
    widget = MyWidget()
    qtbot.addWidget(widget)
    with qtbot.waitSignal(widget.action_triggered, timeout=1000):
        qtbot.mouseClick(widget.button, Qt.LeftButton)
```

## Routing QML Logs to Python Logger

QML `console.log/info/warn/error` calls print to stderr by default with no structure or log levels. Install a custom Qt message handler before creating the QML engine to route them through Python's `logging` module.

### The Handler

```python
import logging
from PySide6.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler

_qt_logger = logging.getLogger("qt.qml")

def _qt_message_handler(msg_type: QtMsgType, context: QMessageLogContext, message: str) -> None:
    file: str = context.file or ""
    line: int = context.line or 0
    location = f" ({file}:{line})" if file else ""
    log_message = f"{message}{location}"

    if msg_type == QtMsgType.QtDebugMsg:
        _qt_logger.debug(log_message)
    elif msg_type == QtMsgType.QtInfoMsg:
        _qt_logger.info(log_message)
    elif msg_type == QtMsgType.QtWarningMsg:
        _qt_logger.warning(log_message)
    else:  # QtCriticalMsg, QtFatalMsg
        _qt_logger.error(log_message)
```

### Install Before QML Engine

```python
qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()
```

Order matters — install before `QQmlApplicationEngine()` so early QML load warnings are captured.

### QML Usage

```qml
Component.onCompleted: {
    console.info("Panel loaded, items: " + listModel.count)
    console.warn("Missing optional property")
    console.error("Failed to load resource")
}
```

### Gotcha: `console.log()` Is Silently Dropped

Qt maps `console.log()` to `QtDebugMsg`, which Qt's own message filtering suppresses **before** the handler is called. The handler never sees it.

| QML call | Qt type | Reaches handler | Recommendation |
|---|---|---|---|
| `console.log()` | `QtDebugMsg` | No | Don't use |
| `console.info()` | `QtInfoMsg` | Yes | Use for debug output |
| `console.warn()` | `QtWarningMsg` | Yes | Recoverable issues |
| `console.error()` | `QtCriticalMsg` | Yes | Errors |

**Always use `console.info()` instead of `console.log()`.**

The logger name `qt.qml` lets you filter or suppress QML messages independently:
```python
logging.getLogger("qt.qml").setLevel(logging.WARNING)  # silence info-level QML noise
```

See the `setting-up-logging` skill for colored stdout/file logging setup that works with this handler.

---

## Platform Integration -  File Dialogs (XDG Desktop Portals)

On Linux, file dialogs use XDG Desktop Portals for native system pickers (with favorites, bookmarks, etc.). The app sets `QT_QPA_PLATFORMTHEME=xdgdesktopportal` at startup if no platform theme is configured.

**Requirements:** `xdg-desktop-portal` + a desktop backend (`xdg-desktop-portal-kde`, `xdg-desktop-portal-gnome`, etc.).

**No code changes needed** — standard `QFileDialog` calls automatically use portals when the platform theme is set. In Flatpak environments, portals are used transparently without any configuration.

---

## Related myai Skills

- **`architecting-changes`** — Parent skill. Language-agnostic architecture decision framework.
- **`engineering-principles`** — Parent skill. Language-agnostic philosophy: architecture separation, async patterns.
- **`building-multi-ui-apps`** — Python-specific multi-interface patterns when Qt GUI shares a core with CLI/API.
- **`setting-up-shortcuts`** — Python-specific keyboard shortcut setup for PySide6 apps.
- **`setting-up-logging`** — Python-specific logging setup including QML log routing.
- **`testing-python`** — Python-specific testing with pytest-qt.
- **`writing-python-code`** — Python-specific coding rules (type system, error handling, async patterns).

