skills/building-qt-apps/SKILL.md
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, qasync integration, signals, system tray, testing.
npx skillsauth add quick-brown-foxxx/coding_rules_python building-qt-appsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Qt apps use PySide6 with qasync for async integration. Architecture follows Manager → Service → Wrapper layering. Never block the event loop.
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
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)
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),
)
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
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())
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)
subprocess.run(), no time.sleep(), no sync HTTPbanned-api__init__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:
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.
__init__)Signal(str), Signal(float). Use Signal(object) only when PySide6 lacks generic signal support — add # PySide6 limitation: no generic signals commentclass 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)
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
...
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._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.
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)
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.
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)
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)
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.
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)
qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()
Order matters — install before QQmlApplicationEngine() so early QML load warnings are captured.
Component.onCompleted: {
console.info("Panel loaded, items: " + listModel.count)
console.warn("Missing optional property")
console.error("Failed to load resource")
}
console.log() Is Silently DroppedQt 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.
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.
development
ALWAYS LOAD THIS SKILL WHEN A NEW FEATURE, NON-TRIVIAL FIX, REFACTOR, OR PYTHON STRUCTURE CHANGE REQUIRES AN ARCHITECTURE DECISION ABOUT LAYERS, WRAPPERS, COMPOSITION ROOTS, FRAMEWORK CHOICES, REUSABLE CORES, OR WHERE CODE SHOULD LIVE. Do not make Python architecture decisions blindly — use this skill first. Python architecture guide + skill router for boundary placement, reusable core design, composition vs inheritance, framework vs custom choices, backend/service layering, and follow-up docs/skills.
tools
ALWAYS LOAD THIS SKILL WHEN CREATING ANY STANDALONE PYTHON SCRIPT OR SINGLE-FILE AUTOMATION. Do not create Python scripts directly — use this skill first. Single-file Python scripts with PEP 723 inline metadata, uv run, and typer CLI.
development
ALWAYS LOAD THIS SKILL WHEN WRITING OR EDITING PYTHON CODE. Do not write or modify Python files directly — use this skill first. Core Python standards: basedpyright strict typing, Result-based error handling, async patterns, security, code style.
development
ALWAYS LOAD THIS SKILL WHEN WRITING TESTS, ADDING FIXTURES, OR SETTING UP PYTEST. Do not write Python tests directly — use this skill first. Python testing with pytest: philosophy, fixtures, mock servers, containerized testing.