Skip to content
Back to skills

Pyside6 Windows Gui App

ASecurity

Build a Windows desktop GUI app with PySide6 — transparent frameless windows with click-through masks, system tray, multi-monitor, mouse-drag state machines, and a smart .bat launcher that handles mul

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
ai-agentspythongobashflaskdebugging

Works with

  • cli

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 6, 2026

npx -y skills add Kairos-ai-agent/kairos-code --skill pyside6-windows-gui-app --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pyside6 Windows Gui App?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pyside6 Windows Gui App
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kairos-ai-agent-pyside6-windows-gui-app/badge)](https://www.skillsdirectory.com/skills/kairos-ai-agent-pyside6-windows-gui-app)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: "pyside6-windows-gui-app"
description: "Build a Windows desktop GUI app with PySide6 — transparent frameless windows with click-through masks, system tray, multi-monitor, mouse-drag state machines, and a smart .bat launcher that handles mul"
priority: 0.5
imported-from: "hermes"
source-path: "hermes/skills/.archive/pyside6-windows-gui-app/SKILL.md"
---
# PySide6 Windows GUI App

Class: building a **native Qt desktop application** for Windows with PySide6 — transparent frameless widgets (desktop pets, widgets, HUDs), system tray apps, or any "sits on the desktop alongside other apps" tool. Distinct from PyInstaller-packaged Flask/WebView stacks (different skill).

## When to load this skill

- User wants a "desktop pet", "floating widget", "always-on-top HUD", or any custom Qt window that lives on the desktop
- Multi-monitor support is needed (screen bounds, drag-across-monitors)
- App needs a Windows system tray icon (QSystemTrayIcon)
- App needs click-through on transparent regions (only the widget body catches mouse)
- User reports "闪退" / "garbled text" / "won't start" — usually a launcher or Python-version problem, not a Qt problem
- Building a launcher `.bat` for a Python GUI app on a machine with multiple Python installs

## Core architecture (transparent-widget pattern)

The single most useful pattern in this skill: a transparent frameless window where ONLY the opaque pixels are clickable, and the rest is fully click-through to whatever's behind.

```python
from PySide6.QtCore import Qt
from PySide6.QtGui import QBitmap, QImage
from PySide6.QtWidgets import QWidget

class PetWindow(QWidget):
    def __init__(self, image_path):
        super().__init__()
        # NOTE: do NOT set Qt.WindowTransparentForInput — it conflicts with setMask
        # (see Pitfalls). WA_TranslucentBackground is enough.
        self.setWindowFlags(
            Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
        )
        self.setAttribute(Qt.WA_TranslucentBackground)
        self.setAttribute(Qt.WA_ShowWithoutActivating)
        self._pixmap = QPixmap(image_path)
        self.resize(self._pixmap.size())
        # Build mask from alpha channel — only opaque pixels catch mouse events
        self._rebuild_mask()

    def _rebuild_mask(self):
        img = self._pixmap.toImage().convertToFormat(QImage.Format_Alpha8)
        self.setMask(QBitmap.fromImage(img))
```

Key insights from this session:
- `setMask(QBitmap.fromImage(alpha_image))` is enough — no `WindowTransparentForInput` flag needed.
- The mask is per-window-position; rebuild it after `resize()` if the widget resizes.
- Multi-monitor: `QGuiApplication.screens()` + union of `availableGeometry()` rects = the full virtual desktop rect.

## Multi-monitor + drag-clamp pattern

Always clamp positions to screen bounds during drag, not after:

```python
def _apply_position(self):
    s = self._screen
    w, h = self.width(), self.height()
    min_x = s.left + w/2; max_x = s.right - w/2
    min_y = s.top + h;    max_y = s.bottom
    self._state.x = max(min_x, min(max_x, self._state.x))
    self._state.y = max(min_y, min(max_y, self._state.y))
    self.move(int(self._state.x - w/2), int(self._state.y - h))
```

The "feet at bottom" convention: store `_state.y` as the bottom-center of the widget. Then `widget_y = state_y - height`. This makes "drop to the floor" and "stand on the screen bottom" trivially correct.

## System tray + QMenu

```python
tray = QSystemTrayIcon()
tray.setIcon(make_icon())  # QIcon from QPixmap
tray.setToolTip("App Name")
menu = QMenu()
menu.addAction("Action 1", handler1)
menu.addSeparator()
menu.addAction("Quit", app.quit)
tray.setContextMenu(menu)
tray.show()
```

**Critical**: do NOT show a blocking `QMessageBox.warning()` on `not QSystemTrayIcon.isSystemTrayAvailable()` — that blocks startup until the user clicks OK, and on a server/headless env it hangs forever. Just log to stderr and continue.

## State machine for behaviors (idle / walk / drag / throw)

For a desktop pet or any animated widget that responds to user input, separate the **state machine** from the **rendering**:

- `behaviors.py` (pure logic): `State` enum, `PetState` dataclass, step functions, physics. No Qt imports. Trivially unit-testable.
- `pet_window.py` (Qt): holds a `PetState`, calls `_tick()` every 33ms from a `QTimer`, advances state, calls `_apply_position()`.

This separation is what lets you write integration tests that run the state machine without spinning up Qt.

For drag → throw physics:
```python
# In mouseMoveEvent: record (t, x, y) into a small ring buffer
# In mouseReleaseEvent: if speed > threshold, compute velocity, enter THROW state
# THROW state: apply gravity, bounce off screen edges with damping, settle when slow
```

## Silent-crash debugging pattern (essential)

A PySide6 app that crashes during startup with no console = invisible to the user. **Always** install a global exception hook that shows a `QMessageBox`:

```python
import sys, traceback
from PySide6.QtWidgets import QMessageBox

def _excepthook(exc_type, exc_value, exc_tb):
    text = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
    print(text, file=sys.stderr)
    try:
        QMessageBox.critical(None, "App - Error", text)
    except Exception:
        pass
    sys.__excepthook__(exc_type, exc_value, exc_tb)

sys.excepthook = _excepthook
```

Also wrap the `PySide6` import itself — that's where version mismatch / missing DLL errors show up:

```python
try:
    from PySide6.QtCore import QTimer
    from PySide6.QtWidgets import QApplication
except Exception as e:
    msg = f"Failed to load PySide6.\n\n{type(e).__name__}: {e}\n\n" \
          "Possible cause: dependencies do not match this Python version."
    print(msg, file=sys.stderr)
    try:
        from PySide6.QtWidgets import QApplication as _QA, QMessageBox
        _qapp = _QA.instance() or _QA(sys.argv)
        QMessageBox.critical(None, "App", msg)
    except Exception: pass
    sys.exit(1)
```

Without this, "闪退" means "something went wrong, good luck figuring out what".

## THE LAUNCHER .BAT — the part that takes 80% of debugging time

This is the single biggest source of "闪退 + 乱码" reports on Windows. The user has `python` on PATH pointing to *some* Python (often hermes venv's 3.11), but they actually develop against MS Store Python 3.13. pip --target-installed numpy is version-specific (cp311 vs cp313 wheel) and will NOT load cross-version.

**The pattern that works** (English output only, smart Python detection, uses pythonw.exe):

```bat
@echo off
setlocal EnableDelayedExpansion

set "PYEXE="
set "PYMINOR="
set "TMPCFG=%TEMP%\pyver_detect_%RANDOM%.txt"

REM Test candidates in priority order. Inline (NO subroutines — label/return
REM parsing is unreliable across cmd.exe versions).
if not exist "%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe" goto :try_pf
del "%TMPCFG%" 2>nul
"%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe" -c "import sys; print(sys.version_info.major, sys.version_info.minor)" > "%TMPCFG%" 2>nul
for /f "tokens=2" %%V in ('type "%TMPCFG%" 2^>nul') do set "PYMINOR=%%V"
if defined PYMINOR if !PYMINOR! GEQ 10 set "PYEXE=%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe"
if defined PYEXE goto :have_python

:try_pf
REM ... try Program Files paths, then PATH (skipping "hermes" venv) ...

:have_python
if exist "%TMPCFG%" del "%TMPCFG%" > nul 2>&1
if not defined PYEXE ( echo [ERROR] No Python 3.10+ found. & pause & exit /b 1 )

REM Match lib dir to detected Python version
if "!PYMINOR!"=="13" set "PYTHONPATH=%~dp0vendor;%~dp0lib;D:/pylib"
if "!PYMINOR!"=="11" set "PYTHONPATH=%~dp0vendor;%~dp0lib;D:/pylib311"

REM Derive pythonw.exe (windowless Python) — python.exe will BLOCK cmd.exe via start
set "PYWEXE=%PYEXE:python.exe=pythonw.exe%"
if not exist "%PYWEXE%" set "PYWEXE=%PYEXE%"   REM fallback

echo Using Python: %PYEXE% (3.%PYMINOR%)
start "" /B "%PYWEXE%" "%~dp0main.py" %*
endlocal
exit /b 0
```

**Hard-won lessons in this launcher:**

1. **English output only.** `chcp 65001` does NOT reliably fix encoding for `echo` in cmd.exe's default rendering. Just don't put Chinese in the bat.

2. **`start ""` with `python.exe` BLOCKS cmd.exe** because python.exe has a console and start inherits it. Use `pythonw.exe` (windowless) for GUI apps — then `start ""` returns instantly.

3. **`for /f` cannot capture MS Store python.exe output directly** (the launcher is a WindowsApps symlink with weird process attributes). Capture to a temp file first:
   ```bat
   "%PYEXE%" -c "..." > "%TMPCFG%" 2>nul
   for /f "tokens=2" %%V in ('type "%TMPCFG%" 2^>nul') do set "PYMINOR=%%V"
   ```

4. **No subroutines + no early `goto :label` from inside sub-blocks.** cmd.exe parses labels inconsistently. Keep the detection inline with `goto :have_python` jumps between sections.

5. **Skip `hermes` in PATH python lookup.** Users often have a hermes-agent venv at `%LOCALAPPDATA%\hermes\hermes-agent\venv\Scripts\python.exe` that's first on PATH. It has different packages. Filter it:
   ```bat
   for /f "delims=" %%P in ('where python.exe 2^>nul') do (
       if not defined PYEXE (
           echo "%%P" | findstr /I "hermes" > nul 2>&1
           if errorlevel 1 ( REM not hermes, use it )
       )
   )
   ```

6. **pip install numpy/PySide6 must go to per-version directories.** MS Store Python's site-packages path is long (triggers Win32 long-path error). Use short paths:
   - Python 3.13: `python3 -m pip install PySide6 Pillow numpy --target D:/pylib`
   - Python 3.11: `python -m pip install PySide6 Pillow numpy --target D:/pylib311`
   - Pillow is mostly abi3-compatible; numpy is the strict version-pinned one.

7. **In your Python `main.py`, select lib dir at runtime by `sys.version_info`:**
   ```python
   _PYLIB_DIR = "D:/pylib311" if sys.version_info[:2] == (3, 11) else "D:/pylib"
   _EXTRA_PATHS = [_PYLIB_DIR, ...]
   for p in _EXTRA_PATHS:
       if p and Path(p).exists() and p not in sys.path:
           sys.path.insert(0, p)
   ```
   **Don't add BOTH pylib paths unconditionally** — the last-inserted ends up at the FRONT of sys.path and Python will try to load the wrong-version numpy first.

## Verification checklist (run after building)

After launching the app via run.bat, verify it's actually alive:

```bash
# 1. App process exists with the tray icon window title
tasklist /v /FO CSV | grep QTrayIconMessageWindow

# 2. Config file got written (your 5-sec save timer proves the event loop is running)
ls -la config.json && stat -c %Y config.json  # mtime should be within last few seconds

# 3. No Python traceback in stderr (if you launched from a console that captures it)
```

## Pitfalls (the bugs that took hours to find)

| Pitfall | Symptom | Fix |
|---|---|---|
| `setWindowFlag(Qt.WindowTransparentForInput, False)` AFTER `show()` | Widget silently disappears | Set the flag once in `__init__`, never again — or just don't use this flag (use `setMask` instead) |
| `PIL.Image.fromarray(arr_int16, "RGBA")` | `TypeError: Cannot handle this data type` | Convert to `uint8` first: `out = arr.astype(np.uint8)` |
| `QFontMetrics.boundingRect(QRectF, flag, text)` | `TypeError: called with wrong argument types` in PySide6 | Use int coords: `boundingRect(x, y, w, h, flag, text)` |
| Multiple `for /f "tokens=2" %%V in ('cmd ...')` inside `if (...)` blocks | PYMINOR ends up empty despite successful detection | Use the temp-file workaround shown above |
| MS Store Python `python -m pip install --target <very-long-path>` | `PermissionError` / long-path error | Install to `D:/pylib` (short path) and add to `PYTHONPATH` |
| Launching `python.exe main.py` from `start ""` in a .bat | cmd.exe never returns — `start` waits for the console child | Use `pythonw.exe` for GUI apps |
| `QMessageBox.warning()` when tray unavailable | Blocks startup until user clicks OK (headless = forever) | Just `print(..., file=sys.stderr)` and continue |

## Bundled resources

- `templates/run.bat` — the smart-launcher template above, ready to drop in
- `templates/main.py` — minimal PySide6 main.py with the exception hook + per-version lib-dir selection
- `references/transparent-widget-pattern.md` — full Qt code for the click-through transparent window + drag + state machine

## Overlay widgets (透明浮窗 / desktop pets / floating tools)

A common subclass of PySide6 desktop apps is the **always-on-top transparent frameless widget** that sits on the desktop like an overlay — desktop pets, floating notes, sticky widgets, Pomodoro overlays, screen watermarks, screenshot tools, recording widgets. The umbrella's `transparent-widget-pattern.md` covers the core `setMask` + click-through pattern; this section catalogs the recurring behavior/tuning recipes.

### When this is the right shape (vs. main-window apps)

If the user asks for any of:
- 桌面宠物 / desktop pet / mascot / floating companion
- 悬浮便签 / sticky note / floating note
- 桌面时钟 / floating clock / 番茄钟 / Pomodoro overlay
- 屏幕水印 / screen watermark / HUD
- 截图工具浮窗 / screenshot widget / quick-capture
- 屏幕录制器浮窗 / screen recorder widget
- 任何"始终在最上层、可拖动、半透明"的桌面 widget

…use the `overlay_window.py` starter below, NOT the `main.py` run.bat pattern.

### Standing toolkit (always reuse)

- `templates/overlay_window.py` — minimal draggable + masked overlay. Copy and customize for any always-on-top semi-transparent widget. Pairs with the `setMask(QBitmap.fromImage(alpha))` pattern from `references/transparent-widget-pattern.md`.
- `templates/overlay_state_machine_pet.py` — full pet with state machine (idle / walk / drag / throw), drag/throw physics, speech bubble, tray icon. Use as the reference impl when the widget has autonomous behaviors.
- `scripts/smoke_test_overlay.py` — drop-in smoke test that spawns an overlay, runs 5s of ticks, checks `isVisible()` and `mask()` is non-empty. Run after building to catch Qt setup bugs early.

### Domain recipes

- `references/overlay-desktop-pet-case-study.md` — concrete worked example (multi-pet manager, persistence, settings dialog, frame animations). The actual 桌面宠物 project's core.
- `references/overlay-image-alpha-pipeline.md` — white-bg → transparent + autocrop + scale, with PIL/numpy pitfalls (`PIL.Image.fromarray(int16) → TypeError`, use `uint8`).
- `references/overlay-multi-monitor-and-physics.md` — screen union via `QGuiApplication.screens()` (NOT `primaryScreen`), throw physics math (200ms sample window for velocity), bounce/friction tuning.

### Common overlays pitfall table

| Pitfall | Symptom | Fix |
|---|---|---|
| `Qt.WindowTransparentForInput` + `setMask()` | Click-through doesn't actually click on opaque pixels | Don't combine them. Use `setMask` alone. |
| `setWindowFlag(...)` AFTER `show()` | Widget silently re-shows / flickers | Set all flags in `__init__`, never modify again at runtime. |
| `Tool` flag | Wants taskbar entry | Use `SplashScreen` instead. |
| Edit resets mask after resize | Click-through stops working | Call `_build_mask()` again after any size change. |
| `int16` to `Image.fromarray(... "RGBA")` | `TypeError: Cannot handle this data type` | Convert to `uint8` first. |
| `QFontMetrics.boundingRect(QRectF, ...)` in PySide6 | `TypeError: called with wrong argument types` | Use int coords: `boundingRect(x, y, w, h, flag, text)`. |
| Frame-number regex without integer sort | `pet_10.png` sorts before `pet_2.png` | Sort by parsed int frame number, not lex. |
| Tray-only app + closing last window | App quits before user can re-show | `app.setQuitOnLastWindowClosed(False)`. |
| `QMessageBox.warning()` when tray unavailable | Blocks startup (headless = forever) | Log to stderr + continue. |

## Absorbed skills

This umbrella subsumes the previously-separate `pyside6-overlay-window` skill (now in `.archive/`). The absorbed content is preserved in the "Overlay widgets" section above plus these files:
- `templates/overlay_window.py`, `templates/overlay_state_machine_pet.py` — starter templates
- `scripts/smoke_test_overlay.py` — verification script
- `references/overlay-*.md` — domain references (multi-monitor, image alpha, pet case study)

If you find yourself loading the archived SKILL.md for anything not already covered by this umbrella, patch this umbrella instead — that's the job-to-be-done.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…