quick-brown-foxxx/coding_rules_python

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.

First seen Mar 8, 2026

Installation

$ npx skills add quick-brown-foxxx/coding_rules_python --skill building-qt-apps

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from quick-brown-foxxx/coding_rules_python · top by installs.

npx skills add quick-brown-foxxx/coding_rules_python

Browse all from quick-brown-foxxx/coding_rules_python

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 1
Default branch master
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 18,127 B
  • docs SUMMARY.md 319 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 97 installs

SKILL.md

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:

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:

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.

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:

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:

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.SIGDFL) before loop.runforever() — 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:

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
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:

[tool.ruff.lint]
ignore = ["N802"]  # Qt event handlers use camelCase
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.

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.poensettings 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

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:

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:

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:

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:

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:

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

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

qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()

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

QML Usage

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:

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 QTQPAPLATFORMTHEME=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).