PyQt6 en style macOS — spécification de l'application de référence
Ci-dessous — la spécification complète pour reproduire assets/app_template.py
bit par bit en termes de comportement. Modélise l'agent designer d'interfaces d'un studio qui crée
applications de bureau de style macOS : palette sobre, une couleur d'accentuation, lumière douce,
animations exactement là où elles expliquent ce qui s'est passé — et pas plus.
Règle principale : « style macOS » dans Qt n'est pas une API native, c'est une esthétique
recréée manuellement : fenêtre sans cadre + QSS personnalisé + QPropertyAnimation pour la fluidité. Ainsi, l'application
s'affiche de la même manière sur Windows, Linux et macOS. Le style natif Qt ne fournit pas d'arrondis,
de semi-transparence ni d'animations.
Référence d'implémentation — assets/app_template.py (un seul fichier). La tâche de l'agent : soit copier
et l'adapter, soit le reconstruire à partir de zéro en suivant précisément ce document.
1. Démarrage rapide
pip install PyQt6
python app_template.py
Construction pour l'utilisateur : pyinstaller --windowed --icon=app.icns app_template.py.
L'application se lance ainsi :
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # seul style respectant QSS partout
app.setFont(system_font(10, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. Structure du fichier app_template.py (ordre important pour la lisibilité)
- Imports :
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 (tous les widgets nécessaires). - Jetons de design :
Theme(dataclass),LIGHT/DARK, constantes de rayons/durées,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Helpers :
system_font,rgba,accent_fg,shadow_color. - Widgets personnalisés dans l'ordre des dépendances :
HoverButton,ToggleSwitch,MacSlider,SegmentedControl,FileDropArea,TrafficLight,SidebarToggle,TitleBar,Sidebar. build_stylesheet(theme).MainWindow+main().
3. Jetons de design (valeurs exactes)
Theme — dataclass figée avec les champs : bg, surface, sidebar, text, text_secondary,
accent, separator_rgb (chaîne #000000/#FFFFFF — base pour 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)
Important : l'accent dans cette application n'est pas bleu. Il est « inversé » — noir
#1D1D1F dans le thème clair et clair #CFCFD4 dans le thème sombre (cela donne le même contraste que
le System Blue, mais dans une gamme noir et blanc). Tout code qui place du texte sur un accent
doit utiliser accent_fg(theme), pas une valeur codée en dur.
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})"
Constantes :
RADIUS_WINDOW = 14,RADIUS_CARD = 12,RADIUS_CONTROL = 8,SIDEBAR_WIDTH = 196.EDGE_MARGIN = 6, masques de bits des bordsEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover/press),DUR_MED = 250(pages, barre latérale, interrupteur, segments),EASE = QEasingCurve.Type.OutCubic.PAGES = ["Обзор", "Задачи", "Ввод", "Чат", "Файлы", "Настройки"].REPLIES— 5 réponses courtes du bot.SVG_EYE(Heroicons, viewBox 0 0 24 24,fill="currentColor") etSVG_EYE_SLASH(Bootstrap, viewBox 0 0 16 16) — chaînes multilignes.
Police : chaîne de fallback dans system_font() — Inter → Segoe UI Variable Text →
.AppleSystemUIFont → SF Pro Text → Helvetica Neue → Manrope → Segoe UI ;
et setStyleStrategy(QFont.StyleStrategy.PreferAntialias).
Marges basées sur l'échelle 4/8/12/16/20/24/32. Ombres douces : cartes — blurRadius 24,
offset (0,5), couleur shadow_color(theme) (noir avec shadow_alpha).
4. Structure de la fenêtre
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) # pour le redimensionnement par les bords
Arbre (_build_ui) : QMainWindow → root (QWidget) → panel (QFrame, objectName "Panel") →
TitleBar (40px) + body (QHBoxLayout) → Sidebar (196px) + separator (1px) + QStackedWidget.
Le panneau reçoit build_stylesheet(theme) — QSS est hérité vers le bas.
L'ombre et l'arrondi vivent sur le QFrame#Panel enfant, pas sur la fenêtre elle-même : la fenêtre est transparente
et fournit une marge pour l'ombre. C'est fondamental — appliquer un effet sur QMainWindow est impossible.
Glisser-déposer et redimensionnement
Déplacer la fenêtre — mécanisme natif Qt6, aucune mathématique manuelle de décalage :
def mousePressEvent(self, event): # dans TitleBar
if event.button() == Qt.MouseButton.LeftButton:
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Changer la taille par les bords — également natif QWindow.startSystemResize. Dans MainWindow
est installé un filtre d'événements sur QApplication, qui :
- sur
MouseButtonPress(Left) dans la zone ≤EDGE_MARGINdu bord de la fenêtre appelle_start_system_resize(edges)et renvoieTrue(l'événement est consommé) ; - sur
MouseMovesans bouton met à jour le curseur de la fenêtre ; - sur
Leaveréinitialise le curseur.
_resize_edges_for(pos) convertit la position globale en masque de bits des bords ; fonctionne uniquement
si la fenêtre est visible et non maximisée. _cursor_for_edges(edges) : centre → Arrow ; gauche/droite
→ SizeHor ; haut/bas → SizeVer ; haut+gauche ou bas+droite → SizeFDiag ; sinon → SizeBDiag.
Piège PyQt6 : Qt.Edge ne peut pas être converti en int et ne peut pas être mélangé avec int par l'opérateur |
(TypeError). Par conséquent, toute la mathématique des bords est basée sur ses propres constantes de bits EDGE_*, et le flag pour
startSystemResize est assemblé ainsi :
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. Widgets personnalisés (paramètres exacts)
HoverButton (bouton avec hover/press progressif)
QPushButton,setFlat(True), hauteur 34, police 13/Medium,padding 0 18px, rayonRADIUS_CONTROL.- Texte en QSS :
color: {accent_fg(theme)}(pas « white » en dur !). - Fond — pas du QSS
:hover(instantané), maisQ_PROPERTY alpha(float, 0..1) : danspaintEventon peint unQColoraccentué avecsetAlphaF(alpha)+ rectangle arrondi, puissuper().paintEvent()dessine le texte. Événements : enter → 0.85, leave → 1.0, press → 0.7, release → 0.85/1.0. AnimationDUR_FAST,EASE.
ToggleSwitch (interrupteur macOS)
QWidget42×24, signaltoggled(bool), gestionnaire seulement sur bouton gauche.Q_PROPERTY knobPos(float 0..1) animéDUR_MED/EASE.- Dessin : piste arrondie (rayon = h/2), couleur interpolée entre
#B9B9BE(éteint) etaccent(allumé) ; poignée — cercleQColor(self._theme.bg), diamètreh-4, positionx_min = 2,x_max = width - knob_d - 2. - Piège : la poignée est peinte avec
theme.bg, pas du blanc — dans le thème sombre, une poignée blanche se confond avec la piste blanche éteinte. setCheckedprogrammatique anime seulement, le signal N'EST PAS émis (émis dansmousePressEvent).
MacSlider (curseur macOS)
QWidgethauteur 28,Q_PROPERTY knobScale(1.0 → 1.12 hover, 0.94 press),DUR_FAST.- Valeur depuis x :
ratio = (x - pad) / (width - 2*pad),pad = 9.5. - Dessin : piste hauteur 6 (fond
separatoralpha 26, remplissage —accent), cercle 9.5px : glow (accent, alpha 22 hover / 34 press, rayon +4), ombre (noir alpha 26), le cercle lui-mêmetheme.bgavec bordseparatoralpha 90 (255 quand pressed),penWidth 1.2.
SegmentedControl (segments « Compact/Moyen/Grand »)
QWidgethauteur 30 ; boutons hauteur 26, police 12,padding 0 16px, boxspacing 4, margins 4.- « Pilule » —
QFrame#SegIndicator(bgaccent, rayon 7) derrière les boutons dans la même disposition, se déplace avecQPropertyAnimation(geometry)DUR_MED/EASE. - Couleur du texte : pour le sélectionné
accent_fg(theme), pour les autrestext_secondary. - Dans
showEvent, abaisser la pilule (lower()) et la placer sur la géométrie du bouton sélectionné.
TrafficLight (feu tricolore)
QPushButton22×22,Q_PROPERTY hover(0..1) animationDUR_MED.- Sans bordures de couleurs jaune/vert : close — rouge
#FF5F57(alpha 200 + 35·hover), minimize/zoom — neutrergba(text_secondary, 60 + 40·hover). - Glyphes dessinés avec
QPenavecRoundCap, largeur 2, coordonnées entières uniquement (voir pièges) :- close — croix de deux lignes ;
- minimize — chevron vers le bas :
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - maximize (non déplié) — chevron vers le haut :
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - maximize en état déplié (
_maximized) — 8 lignes de quatre coins-crochets.
- Couleur du glyphe : pour close — blanc alpha 205+30·hover ; pour les autres — interpolation
text_secondary → textselon hover.
SidebarToggle (bouton de réduction du panneau)
QPushButton22×22,hover0..1, hover-bgrgba(separator,26), glyphe interpolanttext_secondary → text.- Glyphe du panneau ouvert (Lucide
panel-left-close) : cadredrawRoundedRect(QRectF(cx-7, cy-7, 14, 14), 2, 2), séparateur vertical surcx-2, chevron<depuis(cx+4, cy-2)→(cx, cy)→(cx+4, cy+2). - Glyphe du panneau réduit (Lucide
panel-right) : cadre + séparateur surcx+2, pas de chevron. set_collapsed(bool)change le glyphe ; l'état est synchronisé parMainWindow.toggle_sidebar.
Sidebar (navigation)
QWidgetsetFixedWidth(SIDEBAR_WIDTH), margins10,16,10,16, spacing 2.- Marque « MonProjet » (13/Bold,
padding: 0 8px 14px 8px). - Noms des pages —
QPushButton#NavButtonhauteur 30, police 13,checkable, inactif —text_secondary, actif —text. Indicateur —QFramerayon 8, bgrgba(accent,40), animé pargeometryvers le bouton sélectionné (DUR_MED). - Clic inactif sur la page courante — pas d'opération. Dans
showEvent, l'indicateur est placé sans animation.
6. QSS global (build_stylesheet)
#Panel— bg du thème, rayon de la fenêtre.#TitleBar— transparent.#Sidebar— bg de la barre latérale.#Indicator— rgba(accent, 40), rayon 8.QPushButton#NavButton— alignement à gauche, rayon 8, padding-left 10 ;:checked—text;:hover— rgba(separator,26).QPushButton#ToolButton— couleur secondaire, rayon 6, hover surbrillance.- Barres de défilement (ajoutées globalement, pour que le chat et les listes ne soient pas « épaisses ») :
verticale/horizontale 8px, poignée rayon 3
rgba(separator,60)(hover 120), min 30, flèches et pages — transparentes. - Définir
setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)sur tout widget avec des fonds QSS.
7. Pages (6 pièces)
Composée via _page_shell(theme, title) (margins 28/24/28/24, spacing 16, titre en gras 22) et _add_card(theme, layout, title) (carte + titre 14/DemiBold + ombre).
-
Aperçu — carte « Système » (interrupteurs : Démarrage auto ✓, Notifications ✓, Économie d'énergie ✗) et « Affichage » (curseurs Luminosité=70, Volume=45). Ligne du curseur — label à gauche, contrôle à droite avec
control_stretch=1(voir piège sur stretch). -
Tâches — 4
QCheckBoxavec indicateur 18×18 (borderrgba(separator,120), hover →accent, checked → bgaccent) ; barre de progression (12px, chunkaccent), compteur « Terminé N sur M », animationQProgressBarviaQVariantAnimation(280ms) ; boutonHoverButton("Réinitialiser"). -
Saisie — champs :
- Nom, E-mail —
QLineEditnormal (34px, rayon 8, hover bordurergba(sep,140), focus → bordureaccent+ teintergba(accent,8), placeholdertext_secondary, selectionaccent+accent_fg). - Mot de passe —
_build_password_field: action de fin « œil » — icône changeeye↔eye_slash, clic basculeEchoMode.Password↔Normal. - Recherche —
_build_search_field: pill-rayon 17, icône de loupe au début, icône de croix à la fin, visible uniquement quand le texte n'est pas vide (clic — effacement). - Multiligne —
QTextEdit(rayon 10, placeholder viapalette.setColor(QPalette.ColorRole.PlaceholderText, ...)— le sélecteur QSSQTextEdit[placeholderText]n'est pas fiable) + compteur de caractères à droite. - Les icônes des champs sont dessinées dans
_field_icon(18px : loupe = cercle+poignée ; croix = cerclergba(text_secondary,110)+ « ✕ » couleurtheme.bg) ou rendues depuis SVG (_svg_icon) : avant le rendu, on remplace dans le SVGcurrentColorpartheme.text_secondary(voir piège).
- Nom, E-mail —
-
Chat — voir section 8.
-
Fichiers — voir section 9.
-
Paramètres — carte « Apparence » (interrupteur « Thème sombre » →
_on_theme_toggled, segment « Taille de l'interface »), « Comportement » (interrupteurs Mises à jour auto ✓, Animations ✓), combo box « Langue de l'interface » (QSS : champsurface+bordure,QAbstractItemView—selection-background-color: accent; selection-color: accent_fg), curseur « Vitesse des animations »=50.
8. Chat (page « Chat »)
- Conteneur —
QScrollAreatransparente (sans cadre ; pour éviter le fond blanc : QSSbackground: transparent+viewport().setStyleSheet(...transparent)+QWidgetinterne transparent). - Messages stockés dans `self._chat_messages: list[(text, m
... (suite du contenu tronquée)