PyQt6 im Material Design 3 Stil – Spezifikation der Referenzanwendung
Nachfolgend finden Sie die vollständige Spezifikation, mit der sich assets/app_template.py pixelgenau im Verhalten nachbilden lässt. Simulieren Sie einen UI-Design-Agenten aus einem Studio, das MD3-Desktop-Anwendungen entwickelt: Rollenpalette (primary / on-primary / containers / surface / outline), State Layers, Ripple, Rundungen nach der Formskala, Animationen genau dort, wo sie erklären, was passiert ist – und nicht mehr.
Die Hauptregel: „MD3-Stil“ in Qt ist keine native API, sondern eine handgemachte Ästhetik: rahmenloses Fenster + eigenes QSS + QPropertyAnimation für Geschmeidigkeit. So sieht die Anwendung auf Windows, Linux und macOS gleich aus. Der native Stil von Qt bietet keine Rundungen, Transparenz, State Layers und Animationen.
Die Referenzimplementierung ist assets/app_template.py (eine Datei). Aufgabe des Agenten: Entweder kopieren und anpassen oder von Grund auf nach diesem Dokument neu erstellen.
1. Schnellstart
pip install PyQt6
python app_template.py
Build für Benutzer: pyinstaller --windowed --icon=app.ico app_template.py.
Die Anwendung wird so gestartet:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # einziger Stil, der QSS überall respektiert
app.setFont(system_font(14, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. Struktur der Datei app_template.py (Reihenfolge wichtig für Lesbarkeit)
- Importe:
math,sys,time,dataclasses,pathlib.Path; QtCore (Qt,QEvent,QPropertyAnimation,QEasingCurve,QRect(F),QPointF,QParallelAnimationGroup,QVariantAnimation,QTimer,pyqtProperty,pyqtSignal); QtGui (QColor,QFont,QPainter,QPainterPath,QFontDatabase,QFontMetricsF,QPen,QPixmap,QIcon,QPalette); QtSvg (QSvgRenderer); QtWidgets (alle benötigten Widgets). - Design-Token:
Theme(dataclass),LIGHT/DARK, Konstanten für Radien/Dauern,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Helfer:
system_font,rgba,accent_fg,shadow_color,lerp_color,animate_property,draw_checkmark. - Benutzerdefinierte Widgets in Abhängigkeitsreihenfolge:
MdButton,MdSwitch,MdSlider,MdCheckBox,MdSegmentButton/MdSegmentedControl,DropGlyph/FileDropArea,WindowButton,SidebarToggle,TopAppBar,NavButton/NavigationDrawer. build_stylesheet(theme).MainWindow+main().
3. Design-Token (genaue Werte, MD3-Rollen)
Theme – froze dataclass, alle Farben als Hex-Strings, MD3-Rollen:
primary, on_primary, primary_container, on_primary_container,
secondary_container, on_secondary_container, surface, surface_container_low,
surface_container, surface_container_high, surface_container_highest, on_surface,
on_surface_variant, outline, outline_variant, error, on_error, error_container,
on_error_container, shadow_alpha (int 0..255, Schatten ist immer schwarz).
LIGHT = Theme(primary="#6750A4", on_primary="#FFFFFF",
primary_container="#EADDFF", on_primary_container="#21005D",
secondary_container="#E8DEF8", on_secondary_container="#1D192B",
surface="#FEF7FF", surface_container_low="#F7F2FA",
surface_container="#F3EDF7", surface_container_high="#ECE6E0",
surface_container_highest="#E6E0E9", on_surface="#1D1B20",
on_surface_variant="#49454F", outline="#79747E", outline_variant="#CAC4D0",
error="#B3261E", on_error="#FFFFFF",
error_container="#F9DEDC", on_error_container="#410E0B", shadow_alpha=45)
DARK = Theme(primary="#D0BCFF", on_primary="#381E72",
primary_container="#4F378B", on_primary_container="#EADDFF",
secondary_container="#4A4458", on_secondary_container="#E8DEF8",
surface="#141218", surface_container_low="#1D1B20",
surface_container="#211F26", surface_container_high="#2B2930",
surface_container_highest="#36343B", on_surface="#E6E0E9",
on_surface_variant="#CAC4D0", outline="#938F99", outline_variant="#49454F",
error="#F2B8B5", on_error="#601410",
error_container="#8C1D18", on_error_container="#F9DEDC", shadow_alpha=120)
Paare „Rolle / on-Rolle“ sind obligatorisch: Text auf primary – immer on_primary, auf secondary_container – on_secondary_container, auf primary_container – on_primary_container. Kein Hardcode von „white“/„black“. accent_fg(theme) bleibt als Kompatibilitätshelfer erhalten und gibt theme.on_primary zurück.
def rgba(hex_color: str, alpha_0_255: int) -> str: # QSS erwartet Alpha 0..255, nicht 0..1
c = QColor(hex_color)
return f"rgba({c.red()}, {c.green()}, {c.blue()}, {alpha_0_255})"
def shadow_color(theme: Theme) -> QColor:
c = QColor(0, 0, 0); c.setAlpha(theme.shadow_alpha); return c
Konstanten:
- Formen:
RADIUS_WINDOW = 16,RADIUS_CARD = 12,RADIUS_MENU = 8,RADIUS_FIELD = 4,SIDEBAR_WIDTH = 220. Buttons/Pills – radius = height/2; Chips/segmente – voll. EDGE_MARGIN = 12(entspricht dem Rand des Root-Elements für den Schatten), Bitmasken für KantenEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover),DUR_PRESS = 90,DUR_RELEASE = 200,DUR_MED = 250(Seiten, Drawer, Schalter, Segmente),DUR_RIPPLE = 450;EASE = OutCubic,EASE_PRESS = OutQuad.PAGES = ["Übersicht", "Aufgaben", "Eingabe", "Chat", "Dateien", "Einstellungen"].REPLIES– 5 kurze Bot-Antworten.SVG_EYE/SVG_EYE_SLASH– Material Symbolevisibility/visibility_off(viewBox 0 0 24 24,fill="currentColor").
Typografie: system_font(point_size=14, weight) – Kette Roboto → Google Sans → Noto Sans → Segoe UI, setStyleStrategy(PreferAntialias). Größen: Seitenüberschrift 24, Kartenüberschrift 16/Medium, Button-Text 14/Medium, body 14, sekundär 12, Uhrzeit 11. Abstände aus der Skala 4/8/12/16/24/32.
Schatten: Fenster/Panel – blurRadius 24, offset (0,4); Karten (elevation 1) – blurRadius 8, offset (0,1); Farbe shadow_color(theme).
State Layers (über dem Container, Farbe der „on-Rolle“): hover 0.08, press 0.12 (d.h. 0.08*hover + 0.04*press); beim Slider hover 0.10 / press 0.16; beim Kontrollkästchen 0.06 (unchecked) / 0.10 (checked).
4. Fenstergrundgerüst
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setMinimumSize(760, 480)
self.resize(960, 600)
self.setMouseTracking(True)
QApplication.instance().installEventFilter(self) # für Resize an den Rändern
Baum (_build_ui): QMainWindow → root (QWidget, margins 12) → panel (QFrame#Panel, drop-shadow) → TopAppBar (56px) + body (QHBoxLayout) → NavigationDrawer (220px) + separator (1px, outline_variant) + QStackedWidget.
Das Panel erhält build_stylesheet(theme) – QSS wird nach unten vererbt. Zusätzlich setzt _apply_palette(theme) die Rollen von QPalette (Window/Base=surface, Text=on_surface, Highlight=primary, HighlightedText=on_primary, PlaceholderText=on_surface_variant, ToolTipBase=surface_container_high…) für nativ gezeichnete Teile.
Schatten und Rundung leben auf dem untergeordneten QFrame#Panel, nicht auf dem Fenster selbst: Das Fenster ist transparent und sorgt für einen Rand von 12 Pixel für den Schatten. Der Effekt darf nicht auf QMainWindow gesetzt werden.
Ziehen und Größe ändern
Das Fenster bewegen – nativer Mechanismus von Qt6 (in TopAppBar.mousePressEvent):
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Doppelklick auf TopAppBar – Maximieren/Normalisieren.
Die Größe über die Ränder ändern – native QWindow.startSystemResize. In MainWindow ist ein Ereignisfilter für QApplication eingerichtet: Bei MouseButtonPress(Links) im Bereich ≤ EDGE_MARGIN ruft er _start_system_resize(edges) auf und gibt True zurück; bei MouseMove ohne Tasten aktualisiert er den Cursor; bei Leave setzt er den Cursor zurück.
_resize_edges_for(pos) → Bitmaske der Kanten (nur wenn Fenster sichtbar und nicht maximiert).
_cursor_for_edges: Mitte → Arrow; links/rechts → SizeHor; oben/unten → SizeVer; oben+links oder unten+rechts → SizeFDiag; sonst → SizeBDiag.
PyQt6-Falle: Qt.Edge kann nicht in int umgewandelt und nicht mit int über | kombiniert werden. Die Kantenlogik basiert auf eigenen Bit-Konstanten EDGE_*, das Flag wird so zusammengesetzt:
flag = Qt.Edge(0)
if edges & EDGE_TOP: flag |= Qt.Edge.TopEdge
if edges & EDGE_BOTTOM: flag |= Qt.Edge.BottomEdge
if edges & EDGE_LEFT: flag |= Qt.Edge.LeftEdge
if edges & EDGE_RIGHT: flag |= Qt.Edge.RightEdge
handle.startSystemResize(flag)
5. Benutzerdefinierte Widgets (genaue Parameter)
MdButton (filled / tonal, state layer + ripple)
QPushButton, Höhe 40, Pill-Radius 20, Schriftart 14/Medium,padding 0 24px, minWidth = text + 48.kind="filled"→ bgprimary/fgon_primary;kind="tonal"→ bgsecondary_container/fgon_secondary_container.- QSS:
background: transparent; color: {fg}– Hintergrund wird manuell gezeichnet, Text übersuper().paintEvent(). Q_PROPERTY hover/press/ripple(float). State Layer in der Farbe fg:alphaF = 0.08*hover + 0.04*press, Clip über abgerundetem Rechteck.- Ripple:
mousePressEventspeichert den Punkt (_ripple_origin),pressedstartet_ripple_anim0→1 (DUR_RIPPLE); im paint – Ellipse vom Punkt mit Radiusmax_r*ripple(max_r = Entfernung zur entferntesten Ecke + 4),alphaF = 0.18*(1-ripple), Clip über Pill. Die Referenz auf die Animation als Attribut halten.
MdSwitch (MD3-Schalter)
QWidget52×32, Signaltoggled(bool)nur bei Klick;setCheckedanimiert geräuschlos.Q_PROPERTY knobPos(0..1)DUR_MED/EASE, plus hover/press.- Aus: Spur
surface_container_highest+ Umrandung 2pxoutline; Knopf 16px in Farbeoutline, Mittelpunkt x=16. Ein: Spurprimaryohne Umrandung; Knopf 24pxon_primary, Mittelpunkt x=36, innen Häkchen in Farbeprimary(draw_checkmark, pen 2px). Press: Knopf 28px. - Spur/Knopf werden mit
lerp_colorüber knobPos interpoliert; Umrandung verschwindet bei t→1 (alpha = 255*(1-t)); State-Layer-Kreis 40px um den Knopf.
MdSlider
QWidgetHöhe 28,valueChanged(int). Spur 4px, Radius 2: inaktivsurface_container_highest, aktivprimary. Knopf – Kreis 20pxprimary+ leichter Schatten (schwarz alpha 30, offset y+1). State Layer 40pxprimaryalphaF = 0.10*hover + 0.06*press.- Wert aus x:
pad = 10(Knopfradius),ratio = (x-pad)/(width-2*pad); bei Drag – ohne Trägheit, sonst wirdfillmitDUR_FASTanimiert.
MdCheckBox
QWidgetHöhe 24, Schriftart 14, minWidth = 34 + Text. Box 18×18, Radius 2 bei x=3.- Unchecked: Umrandung 2px
on_surface_variant. Checked: Füllungprimary+ Häkchenon_primary(pen 2px RoundCap/RoundJoin), Häkchen wird progressiv „gezeichnet“ (check0..1, zwei Segmentlinien überdrawPath). - State Layer 36px:
on_surface0.06 (unchecked) /primary0.10 (checked) * hover. boxScale(OutBack) schrumpft die Box bei Press;setChecked(checked, animate)sendettoggled; Klick inmouseReleaseEventnur, wenn sich Cursor innerhalb des Rechtecks befindet.
MdSegmentedControl / MdSegmentButton
- Container Höhe 40, Pill-Radius 20, Umrandung 1px
outline(wird in paintEvent gezeichnet). - Segmente gleicher Breite (
addWidget(btn, 1)), Höhe 38, Schriftart 14/Medium; margins (1,1,1,1), spacing 0. Trenner 1pxoutlinezwischen NICHT ausgewählten Nachbarn (werden in paintEvent gezeichnet, int-Koordinaten). - Indikator –
QFrame#SegIndicator(bgsecondary_container, radius 19) hinter den Tasten,QPropertyAnimation(geometry)DUR_MED; inshowEvent–lower()+ Geometrie ohne Animation. - Ausgewähltes Segment: Text
on_secondary_container+ Häkchen links vom Text (draw_checkmark); nicht ausgewählt:on_surface. Textfarbe wird animiert (colorQColor-Eigenschaft). State Layer des Segments – Pill-Farbe der on-Rolle.
DropGlyph / FileDropArea
- Glyph 38×38: Pfeil nach unten + Fach, Farbe
lerp(on_surface_variant, primary, level), Haloprimaryalpha 0.10*level. FileDropArea(QFrame): minHeight 150,setAcceptDrops(True). Hintergrundsurface_container_lowradius 16; gepunktete Linie 2pxoutline_variant(dashPattern [4,3.5]); bei hover/drag – gepunktete Linieprimary+ Overlayprimary_containeralpha ≈ 60glow+20press.dragEnterEvent(hasUrls)/dragLeaveEvent/dropEvent(nurisLocalFile()) →dropped(list[str]); Klick →QFileDialog.getOpenFileNames. Die gesamte Signallogik bleibt unverändert.
WindowButton (Fenster-Schaltflächen)
QPushButton40×40, radius 20,Q_PROPERTY hover/press,set_maximized(bool).- Glyphen
QPen1.8 RoundCap/RoundJoin, int-Koordinaten: minimieren – horizontale Linie; maximieren – Quadrat 12×12 (outline), bei Vollbild – zwei überlagerte Quadrate; schließen – Kreuz X. - Glyphenfarbe
lerp(on_surface_variant, on_surface, hover); close bei hover – Hintergrunderror(Kreis 40px) + Glyphon_error. Andere bei hover – state layeron_surface0.08. - Reihenfolge rechts: minimieren, maximieren, schließen.
SidebarToggle
QPushButton40×40, state layeron_surface0.08, Glyphlerp(on_surface_variant, on_surface, hover).- Glyph: Rahmen 16×16, radius 2 + Trennlinie; geöffnete Leiste – Trennlinie links + Chevron
<(panel-left-close); zugeklappt – Trennlinie rechts (panel-left-open). set_collapsed(bool); synchronisiertMainWindow.toggle_sidebar.
TopAppBar
QWidget#TopAppBarHöhe 56, transparent über Panel; margins (8,0,8,0), spacing 4.- Links
SidebarToggle, dann Titel „MeinProjekt“ 18/Mediumon_surface, stretch, rechtsWindowButtonminimieren/maximieren/schließen._max_btn+_sync_max_stateüber eventFilter aufWindowStateChange.startSystemMove+ Doppelklick maximieren.
NavButton / NavigationDrawer
NavButton(QPushButton#NavButton)Höhe 48, Schriftart 14, checkable; Icon 20×20 bei x=16 wird mitQPaintergezeichnet (Material Symbols outlined): dashboard (4 Quadrate), tasks (Kreis+Haken), edit (Bleistift), chat (Sprechblase), folder, tune (3 Linien + Knöpfe). Text über QSSpadding-left: 48px.nextCheckState()– überschreibt Qt-Standard;paintEventzeichnet: beichecked– bgsecondary_containerradius 12 + akzentuierte Form (Pill) überoutline_variant; state layeron_surface0.10*(hover+press). Hervorhebungslinie: Rechteck 3×24 radius 2primarylinksbündig (nur beichecked). Textfarbeon_surface_variant(unchecked) /on_secondary_container(checked).NavigationDrawer(QFrame): Breite =SIDEBAR_WIDTHin der Drawer-Ansicht, collapse → width 0 animiertDUR_MED(minimumWidth-Eigenschaft). Separator rechts.
6. Seiten (QStackedWidget)
Jede Seite ist ein QWidget ohne Rahmen/Layout-Elemente, das beim Wechsel mit topLevelChanged.connect(_animate_page_change) auf currentChanged animiert wird (leichte horizontale Verschiebung von 20px und Deckkraft der alten Seite, DUR_MED).
Inhalt:
- Übersicht: Titel + 5 Karten (Material-Karten, Schatten, radius 12, bg
surface_container_low+ innere padding 16). - Aufgaben: 3 Zeilen mit Checkbox + Text,
QScrollAreaohne Rahmen. - Eingabe: Feld
QLineEdit(full-width radius field 4,outlineborder + Fokusprimary),MdSegmentButtonlinks/rechts/mittig, darunter Schalter + Schieberegler + Kontrollkästchen. DarunterMdButtonfilled/tonal (als Beispiel). - Chat:
QListWidgetItemmitDelegate(ChatMessage)– Nachrichtenhintergrundsurface_containerradius 12 (Benutzer) /secondary_container(Bot),on_surface_variant12px Zeitstempel, 16px Monospace für Codeblöcke. EingabezeileQLineEdit+ Senden-Button. - Dateien:
FileDropArea+ Liste mit Icons (Ordner/Datei ausQIcon.fromTheme). - Einstellungen: Schalter für Helligkeit + 3
QSlider+MdSegmentedControl(3 Segmente) + 2MdCheckBox+MdButton„Zurücksetzen“.
7. Theme-Wechsel
self._theme = DARK if self._theme is LIGHT else LIGHT
self._update_theme() # ruft build_stylesheet + _apply_palette auf
_update_theme():
self.panel.setStyleSheet(build_stylesheet(self._theme))– nur auf dem Panel.self._apply_palette(self._theme)– setzt QPalette-Rollen.- Durchläuft alle Kinder:
setStyleSheet(``)zurücksetzen (erbt jetzt),update(). - Aktualisiert Schattenfarbe der Effekte (wenn vorhanden).
self.nav_drawer.update(),self._set_page(self._current_page)– Seite neu anzeigen.
8. build_stylesheet – wichtigste Selektoren
def build_stylesheet(t: Theme) -> str:
return f"""
#Panel {{
background: {t.surface};
border-radius: {RADIUS_WINDOW}px;
}}
QWidget {{
font-family: "Roboto", "Google Sans", "Noto Sans", "Segoe UI";
}}
NavButton {{
text-align: left; padding-left: 48px;
}}
NavButton:checked {{
background: {t.secondary_container}; border-radius: 12px;
color: {t.on_secondary_container};
}}
QScrollBar:vertical {{
background: transparent; width: 12px; margin: 0;
}}
QScrollBar::handle:vertical {{
background: {rgba(t.on_surface_variant, 51)}; border-radius: 6px;
min-height: 30px; margin: 4px;
}}
QScrollBar::handle:vertical:hover {{
background: {rgba(t.on_surface_variant, 77)};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0; background: none;
}}
#SegIndicator {{
background: {t.secondary_container}; border-radius: 19px;
}}
#FileDrop {{
background: {t.surface_container_low}; border-radius: {RADIUS_CARD}px;
}}
"""
9. Wichtige PyQt6-Fallen
QPropertyAnimationakzeptiert keine Python-float/number-Eigenschaften, die nicht alspyqtPropertydeklariert sind. Jede animierbare Zahl MUSS@pyqtProperty(float)sein.Qt.Edgeundintkönnen nicht mit|gemischt werden. Der Edge-Flag wird mitQt.Edge(x)erstellt.QSystemTrayIconist nicht verfügbar. Wird im Referenzcode nicht verwendet.setStyleSheetauf dem Panel ersetzt nicht das native Styling vonQComboBoxetc._apply_palettesetzt explizitQPalette-Rollen, die von Qt für native Teile verwendet werden.- Animationen müssen vor der Zerstörung gestoppt werden: Speichere immer eine starke Referenz auf laufende Animationen, sonst werden sie gelöscht und die Anwendung stürzt ab.
QPropertyAnimation(self, b"knobPos", self)im Konstruktor initialisieren und als Attribut speichern. QLineEdit-Palette für Platzhalter:PlaceholderText-Rolle wird nur bei aktivem QSS gesetzt.QListWidgetItem-Delegat erbt vonQStyledItemDelegate,sizeHintmuss die genaue Höhe zurückgeben; andernfalls werden Elemente abgeschnitten.
10. Komplette Implementierungsreihenfolge
- Theme + Konstanten + Helfer (keine Widgets).
MdeButton– testbar (wird als Erster verwendet).MdSwitch– testbar (Slider ähnlich).MdSlider– testbar (benötigt nur paintEvent).MdCheckBox– testbar (benötigt Slider nicht).MdSegmentButton+MdSegmentedControl– benötigen Container.DropGlyph+FileDropArea– benötigt kein anderes Widget.WindowButton+SidebarToggle– benötigenTopAppBar.TopAppBar– dient als Header.NavButton+NavigationDrawer– benötigenTopAppBarundMainWindow.build_stylesheet+_apply_palette.MainWindow– fügt alles zusammen + Seiten + MainWindow-Logik.main()-Funktion.
11. "Chat"-Seite – Prompt Inc.
- Der Benutzer gibt im
QLineEdit„Texteingabe“ eine Nachricht ein und drückt die Eingabetaste. - Die Chat-Nachricht wird an den unteren Rand von
QListWidgetangehängt (Benutzer). - Daraufhin erscheint eine Bot-Antwort (aus dem Array
REPLIESvon 5 Elementen). - Der Bot antwortet sofort (kein Timer).
- Beim Neuladen der Seite wird der Chat zurückgesetzt.
12. Anforderungen an die Code-Qualität (für das KI-Modell)
- Voll funktionsfähig: Keine Platzhalter, kein
pass, keine# TODO, keineraise NotImplementedError. - Typhinweise: Jede öffentliche Methode hat volle Typannotationen (Python 3.10).
- Höchstens eine Warnung von mypy/pyright, Typ-Vereinfachungen erlaubt, wenn nötig.
flake8-konform: Zeilenlänge, Leerzeichen, Importe.- Keine zusätzlichen Abhängigkeiten außer PyQt6 und Python ≥3.10.
- Logischer Ablauf und Lesbarkeit (gut benannte Funktionen, keine 500-Zeilen-Funktionen).
Diese Spezifikation ist deterministisch – der generierte Code muss app_template.py nach Verhalten und Layout reproduzieren. Abweichungen nur, wenn sie das visuelle Ergebnis verbessern, ohne die MD3-Konformität zu verletzen.