PyQt6 im macOS-Stil — Spezifikation der Referenzanwendung
Unten ist die vollständige Spezifikation, um assets/app_template.py
Byte für Byte im Verhalten zu reproduzieren. Modelliere einen UI-Designer-Agenten aus einem Studio, das
macOS-ähnliche Desktop-Anwendungen erstellt: gedämpfte Palette, eine Akzentfarbe, weiches Licht,
Animationen genau dort, wo sie erklären, was passiert ist – und nicht mehr.
Hauptregel: „macOS-Stil“ in Qt ist keine native API, sondern eine von Hand nachgebildete Ästhetik:
rahmenloses Fenster + eigenes QSS + QPropertyAnimation für Flüssigkeit. So sieht die Anwendung
auf Windows, Linux und macOS gleich aus. Der native Qt-Stil bietet keine abgerundeten Ecken,
Transparenz und Animationen.
Die Referenzimplementierung ist assets/app_template.py (eine Datei). Die Aufgabe des Agenten ist entweder, sie zu kopieren
und anzupassen oder sie von Grund auf genau nach diesem Dokument neu zu erstellen.
1. Schnellstart
pip install PyQt6
python app_template.py
Build für den Benutzer: pyinstaller --windowed --icon=app.icns 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(10, 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:
sys,time,dataclasses,pathlib.Path; QtCore (Qt,QEvent,QPropertyAnimation,QEasingCurve,QRect(F),QPointF,QParallelAnimationGroup,QVariantAnimation,QTimer,pyqtProperty,pyqtSignal); QtGui (QColor,QFont,QPainter,QFontDatabase,QPen,QPixmap,QIcon,QPalette); QtSvg (QSvgRenderer); QtWidgets (alle benötigten Widgets). - Design-Tokens:
Theme(dataclass),LIGHT/DARK, Konstanten für Radien/Dauern,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Hilfsfunktionen:
system_font,rgba,accent_fg,shadow_color. - Benutzerdefinierte Widgets in Abhängigkeitsreihenfolge:
HoverButton,ToggleSwitch,MacSlider,SegmentedControl,FileDropArea,TrafficLight,SidebarToggle,TitleBar,Sidebar. build_stylesheet(theme).MainWindow+main().
3. Design-Tokens (exakte Werte)
Theme — frozen dataclass mit Feldern: bg, surface, sidebar, text, text_secondary,
accent, separator_rgb (Zeichenkette #000000/#FFFFFF — Basis für rgba()), separator_alpha
(0..255), shadow_alpha (0..255).
LIGHT = Theme(bg="#F5F5F7", surface="#FFFFFF", sidebar="#ECECEF", text="#1D1D1F",
text_secondary="#6E6E73", accent="#1D1D1F",
separator_rgb="#000000", separator_alpha=20, shadow_alpha=55)
DARK = Theme(bg="#1E1E1E", surface="#2A2A2C", sidebar="#242426", text="#F5F5F7",
text_secondary="#98989D", accent="#CFCFD4",
separator_rgb="#FFFFFF", separator_alpha=20, shadow_alpha=150)
Wichtig: Der Akzent in dieser Anwendung ist nicht blau. Er ist „invertiert“ – schwarz
#1D1D1F im hellen Thema und hell #CFCFD4 im dunklen (dies bietet denselben Kontrast wie
System Blue, aber in Schwarz-Weiß). Jeglicher Code, der Text über den Akzent legt,
muss accent_fg(theme) verwenden, nicht hartcodieren.
def accent_fg(theme: Theme) -> str:
color = QColor(theme.accent)
luminance = (0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) / 255
return "#1D1D1F" if luminance > 0.6 else "#FFFFFF"
def rgba(hex_color: str, alpha_0_255: int) -> str:
c = QColor(hex_color)
return f"rgba({c.red()}, {c.green()}, {c.blue()}, {alpha_0_255})"
Konstanten:
RADIUS_WINDOW = 14,RADIUS_CARD = 12,RADIUS_CONTROL = 8,SIDEBAR_WIDTH = 196.EDGE_MARGIN = 6, Bitmasken für KantenEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover/press),DUR_MED = 250(Seiten, Seitenleiste, Schalter, Segmente),EASE = QEasingCurve.Type.OutCubic.PAGES = ["Übersicht", "Aufgaben", "Eingabe", "Chat", "Dateien", "Einstellungen"].REPLIES— 5 kurze Bot-Antworten.SVG_EYE(Heroicons, viewBox 0 0 24 24,fill="currentColor") undSVG_EYE_SLASH(Bootstrap, viewBox 0 0 16 16) — mehrzeilige Zeichenketten.
Schriftart: Fallback-Kette in system_font() — Inter → Segoe UI Variable Text →
.AppleSystemUIFont → SF Pro Text → Helvetica Neue → Manrope → Segoe UI;
plus setStyleStrategy(QFont.StyleStrategy.PreferAntialias).
Abstände aus der Skala 4/8/12/16/20/24/32. Schatten weich: Karten — blurRadius 24,
offset (0,5), Farbe shadow_color(theme) (schwarz mit shadow_alpha).
4. Fensterrahmen
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 Größenänderung an den Kanten
Baum (_build_ui): QMainWindow → root (QWidget) → panel (QFrame, objectName "Panel") →
TitleBar (40px) + body (QHBoxLayout) → Sidebar (196px) + separator (1px) + QStackedWidget.
Das Panel erhält build_stylesheet(theme) — QSS wird nach unten vererbt.
Schatten und Abrundung leben auf dem untergeordneten QFrame#Panel, nicht auf dem Fenster selbst: Das Fenster ist transparent
und bietet Platz für den Schatten. Dies ist grundlegend – das Anhängen eines Effekts an QMainWindow ist nicht möglich.
Ziehen und Größenänderung
Fenster bewegen — nativer Mechanismus von Qt6, keine manuelle Verschiebungsmathematik:
def mousePressEvent(self, event): # in TitleBar
if event.button() == Qt.MouseButton.LeftButton:
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Größe an den Kanten ändern — ebenfalls natives QWindow.startSystemResize. In MainWindow
ist ein Ereignisfilter auf QApplication installiert, der:
- bei
MouseButtonPress(Links) im Bereich ≤EDGE_MARGINvom Fensterrand_start_system_resize(edges)aufruft undTruezurückgibt (Ereignis wird verbraucht); - bei
MouseMoveohne Tasten den Cursor des Fensters aktualisiert; - bei
Leaveden Cursor zurücksetzt.
_resize_edges_for(pos) übersetzt die globale Position in eine Bitmaske der Kanten; funktioniert nur,
wenn das Fenster sichtbar und nicht maximiert ist. _cursor_for_edges(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 dem |-Operator mit int gemischt werden
(TypeError). Daher wird die gesamte Kantenmathematik mit eigenen Bitkonstanten EDGE_* durchgeführt, und das Flag für
startSystemResize 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)
HoverButton (Taste mit fließendem hover/press)
QPushButton,setFlat(True), Höhe 34, Schriftart 13/Medium,padding 0 18px, RadiusRADIUS_CONTROL.- Text in QSS:
color: {accent_fg(theme)}(nicht hartcodiert „white“!). - Hintergrund — nicht QSS
:hover(sofortig), sondernQ_PROPERTY alpha(float, 0..1): impaintEventwird eine AkzentfarbeQColormitsetAlphaF(alpha)+ abgerundetes Rechteck gemalt, dann zeichnetsuper().paintEvent()den Text. Ereignisse: enter → 0.85, leave → 1.0, press → 0.7, release → 0.85/1.0. AnimationDUR_FAST,EASE.
ToggleSwitch (macOS-Schalter)
QWidget42×24, Signaltoggled(bool), Handler nur bei linker Maustaste.Q_PROPERTY knobPos(float 0..1) wird mitDUR_MED/EASEanimiert.- Zeichnung: Spur abgerundet (radius = h/2), Farbe interpoliert zwischen
#B9B9BE(aus) undaccent(ein); Knopf — KreisQColor(self._theme.bg), Durchmesserh-4, Positionx_min = 2,x_max = width - knob_d - 2. - Falle: Der Knopf wird mit
theme.bggefärbt, nicht mit Weiß – im dunklen Thema verschmilzt ein weißer Knopf mit der weißen, ausgeschalteten Spur. - Programmatisches
setCheckedanimiert nur, Signal wird NICHT emittiert (emittiert inmousePressEvent).
MacSlider (macOS-Schieberegler)
QWidgetHöhe 28,Q_PROPERTY knobScale(1.0 → 1.12 hover, 0.94 press),DUR_FAST.- Wert aus x:
ratio = (x - pad) / (width - 2*pad),pad = 9.5. - Zeichnung: Spurhöhe 6 (Hintergrund
separatormit alpha 26, Füllung —accent), Kreis 9.5px: Glanz (accent, alpha 22 hover / 34 press, radius +4), Schatten (schwarz alpha 26), der Kreis selbsttheme.bgmit Randseparatoralpha 90 (255 bei pressed),penWidth 1.2.
SegmentedControl (Segmente „Kompakt/Mittel/Groß“)
QWidgetHöhe 30; Tasten Höhe 26, Schriftart 12,padding 0 16px, Boxspacing 4, margins 4.- „Pille“ —
QFrame#SegIndicator(bgaccent, radius 7) hinter den Tasten im selben Layout, bewegt sich mitQPropertyAnimation(geometry)DUR_MED/EASE. - Textfarbe: bei ausgewählter
accent_fg(theme), bei den restlichentext_secondary. - In
showEventdie Pille nach unten schieben (lower()) und auf die Geometrie der ausgewählten Taste setzen.
TrafficLight (Ampel)
QPushButton22×22,Q_PROPERTY hover(0..1) AnimationDUR_MED.- Ohne Gelb/Grün-Farbrahmen: close — rot
#FF5F57(alpha 200 + 35·hover), minimize/zoom — neutralrgba(text_secondary, 60 + 40·hover). - Glyphen werden mit
QPenmitRoundCapgezeichnet, Breite 2, nur ganzzahlige Koordinaten (siehe Fallen):- close — Kreuz aus zwei Linien;
- minimize — Chevron nach unten:
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - maximize (nicht maximiert) — Chevron nach oben:
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - maximize im maximierten Zustand (
_maximized) — 8 Linien von vier Eckklammern.
- Glyphenfarbe: für close — weiß alpha 205+30·hover; für die anderen — Interpolation
text_secondary → textnach hover.
SidebarToggle (Schaltfläche zum Einklappen der Seitenleiste)
QPushButton22×22,hover0..1, hover-bgrgba(separator,26), Glyph interpolierttext_secondary → text.- Glyph der geöffneten Seitenleiste (Lucide
panel-left-close): RahmendrawRoundedRect(QRectF(cx-7, cy-7, 14, 14), 2, 2), vertikaler Trenner beicx-2, Chevron<aus(cx+4, cy-2)→(cx, cy)→(cx+4, cy+2). - Glyph der eingeklappten (Lucide
panel-right): Rahmen + Trenner beicx+2, kein Chevron. set_collapsed(bool)schaltet den Glyph um; der Zustand wird vonMainWindow.toggle_sidebarsynchronisiert.
Sidebar (Navigation)
QWidgetsetFixedWidth(SIDEBAR_WIDTH), margins10,16,10,16, spacing 2.- Marke „MeinProjekt“ (13/Bold,
padding: 0 8px 14px 8px). - Seitennamen —
QPushButton#NavButtonHöhe 30, Schriftart 13,checkable, inaktiv —text_secondary, aktiv —text. Indikator —QFrameRadius 8, bgrgba(accent,40), animiert übergeometryzur ausgewählten Taste (DUR_MED). - Inaktiver Klick auf die aktuelle Seite — no-op. In
showEventwird der Indikator ohne Animation gesetzt.
6. Allgemeines QSS (build_stylesheet)
#Panel— bg des Themas, Radius des Fensters.#TitleBar— transparent.#Sidebar— bg der Seitenleiste.#Indicator— rgba(accent, 40), radius 8.QPushButton#NavButton— linksbündig, radius 8, padding-left 10;:checked—text;:hover— rgba(separator,26).QPushButton#ToolButton— sekundäre Farbe, radius 6, hover-Hervorhebung.- Bildlaufleisten (global hinzugefügt, damit Chat und Listen nicht „dick“ sind):
vertikal/horizontal 8px, handle radius 3
rgba(separator,60)(hover 120), min 30, Pfeile und Seiten — transparent. setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)auf jedem Widget mit QSS-Hintergründen setzen.
7. Seiten (6 Stück)
Wird über _page_shell(theme, title) (margins 28/24/28/24, spacing 16, Überschrift 22/Bold) und _add_card(theme, layout, title) (Karte + Überschrift 14/DemiBold + shadow) zusammengestellt.
-
Übersicht — Karte „System“ (Schalter: Autostart ✓, Benachrichtigungen ✓, Energiesparen ✗) und „Anzeige“ (Schieberegler Helligkeit=70, Lautstärke=45). Die Schieberegler-Zeile — label links, control rechts mit
control_stretch=1(siehe Falle zu stretch). -
Aufgaben — 4
QCheckBoxmit Indikator 18×18 (borderrgba(separator,120), hover →accent, checked → bgaccent); Fortschrittsbalken (12px, chunkaccent), Zähler „Erledigt N von M“, AnimationQProgressBarüberQVariantAnimation(280ms); TasteHoverButton("Zurücksetzen"). -
Eingabe — Felder:
- Name, E-Mail — normale
QLineEdit(34px, radius 8, hover Rahmenrgba(sep,140), focus → Rahmenaccent+ tintrgba(accent,8), placeholdertext_secondary, selectionaccent+accent_fg). - Passwort —
_build_password_field: trailing-Aktion „Auge“ — Symbol wechselteye↔eye_slash, Klick schaltetEchoMode.Password↔Normalum. - Suche —
_build_search_field: pill-radius 17, leading-Symbol Lupe, trailing-Symbol Kreuz, nur sichtbar bei nicht leerem Text (Klick — leeren). - Mehrzeilig —
QTextEdit(radius 10, placeholder überpalette.setColor(QPalette.ColorRole.PlaceholderText, ...)— QSS-SelektorQTextEdit[placeholderText]ist unzuverlässig) + Zeichenzähler rechts. - Feld-Symbole werden in
_field_icongezeichnet (18px: Lupe = Kreis+f-Stift; Kreuz = Kreisrgba(text_secondary,110)+ „✕“ in Farbetheme.bg) oder aus SVG gerendert (_svg_icon): vor dem Rendern wird im SVGcurrentColordurchtheme.text_secondaryersetzt (siehe Falle).
- Name, E-Mail — normale
-
Chat — siehe Abschnitt 8.
-
Dateien — siehe Abschnitt 9.
-
Einstellungen — Karte „Darstellung“ (Schalter „Dunkles Thema“ →
_on_theme_toggled, Segment „Schnittstellengröße“), „Verhalten“ (Schalter Auto-Updates ✓, Animationen ✓), Kombobox „Sprache der Benutzeroberfläche“ (QSS: Feldsurface+Rahmen,QAbstractItemView—selection-background-color: accent; selection-color: accent_fg), Schieberegler „Animationsgeschwindigkeit“=50.
8. Chat (Seite „Chat“)
- Container — transparentes
QScrollArea(rahmenlos; um weißen Hintergrund zu vermeiden: QSSbackground: transparent+viewport().setStyleSheet(...transparent)+ inneresQWidgettransparent). - Nachrichten gespeichert in `self._chat_messages: list[(text, m
[truncated for translation]