PyQt6 en style Material Design 3 — spécification de l'application de référence
Ci-dessous se trouve la spécification complète pour reproduire assets/app_template.py
comportement par comportement. Modélise l'agent designer d'interfaces d'un studio qui crée
des applications MD3 de bureau : palette de rôles (primary / on-primary / containers / surface /
outline), calques d'état (state layers), ondulation (ripple), arrondis selon l'échelle des formes,
animations exactement là où elles expliquent ce qui s'est passé — et pas plus.
Règle principale : « style MD3 » dans Qt n'est pas une API native, mais une esthétique recréée manuellement :
fenêtre sans cadre + QSS personnalisé + QPropertyAnimation pour la fluidité. Ainsi l'application
a la même apparence sur Windows, Linux et macOS. Le style Qt natif ne permet pas les arrondis,
la transparence, les calques d'état et les animations.
La réalisation de référence est 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 exactement selon ce document.
1. Démarrage rapide
pip install PyQt6
python app_template.py
Construction pour l'utilisateur : pyinstaller --windowed --icon=app.ico 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(14, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. Structure du fichier app_template.py (l'ordre est important pour la lisibilité)
- Imports :
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 (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,lerp_color,animate_property,draw_checkmark. - Widgets personnalisés dans l'ordre des dépendances :
MdButton,MdSwitch,MdSlider,MdCheckBox,MdSegmentButton/MdSegmentedControl,DropGlyph/FileDropArea,WindowButton,SidebarToggle,TopAppBar,NavButton/NavigationDrawer. build_stylesheet(theme).MainWindow+main().
3. Jetons de design (valeurs exactes, rôles MD3)
Theme — dataclass figée (frozen), toutes les couleurs sont des chaînes hexadécimales, rôles MD3 :
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, l'ombre est toujours noire).
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="#ECE6F0",
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)
Les paires « rôle / on-rôle » sont obligatoires : le texte au-dessus de primary — toujours on_primary, au-dessus
de secondary_container — on_secondary_container, au-dessus de primary_container —
on_primary_container. Pas de code en dur « white »/« black ». accent_fg(theme) est conservé comme
helper de compatibilité et retourne theme.on_primary.
def rgba(hex_color: str, alpha_0_255: int) -> str: # QSS attend un alpha 0..255, pas 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
Constantes :
- Formes :
RADIUS_WINDOW = 16,RADIUS_CARD = 12,RADIUS_MENU = 8,RADIUS_FIELD = 4,SIDEBAR_WIDTH = 220. Boutons/pilules — radius = hauteur / 2 ; chips/segments — full. EDGE_MARGIN = 12(correspond à la marge de la racine sous l'ombre), masques de bits des bordsEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover),DUR_PRESS = 90,DUR_RELEASE = 200,DUR_MED = 250(pages, tiroir, interrupteur, segments),DUR_RIPPLE = 450;EASE = OutCubic,EASE_PRESS = OutQuad.PAGES = ["Vue d'ensemble", "Tâches", "Saisie", "Chat", "Fichiers", "Paramètres"].REPLIES— 5 réponses courtes du bot.SVG_EYE/SVG_EYE_SLASH— Symboles Materialvisibility/visibility_off(viewBox 0 0 24 24,fill="currentColor").
Typographie : system_font(point_size=14, weight) — chaîne Roboto → Google Sans →
Noto Sans → Segoe UI, setStyleStrategy(PreferAntialias). Tailles : titre de page 24,
titre de carte 16/Medium, texte de bouton 14/Medium, body 14, secondaire 12, heure 11.
Marges selon l'échelle 4/8/12/16/24/32.
Ombres : fenêtre/panneau — blurRadius 24, offset (0,4) ; cartes (élévation 1) —
blurRadius 8, offset (0,1) ; couleur shadow_color(theme).
Calques d'état (au-dessus du conteneur, couleur du « on-rôle ») : hover 0.08, press 0.12 (c'est-à-dire
0.08*hover + 0.04*press) ; pour le curseur hover 0.10 / press 0.16 ; pour la case à cocher 0.06 (non cochée)
/ 0.10 (cochée).
4. Squelette 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
Arborescence (_build_ui) : QMainWindow → root (QWidget, margins 12) →
panel (QFrame#Panel, drop-shadow) → TopAppBar (56px) + body (QHBoxLayout) →
NavigationDrawer (220px) + separator (1px, outline_variant) + QStackedWidget.
Le panneau reçoit build_stylesheet(theme) — QSS est hérité vers le bas. En complément,
_apply_palette(theme) définit les rôles QPalette (Window/Base=surface, Text=on_surface,
Highlight=primary, HighlightedText=on_primary, PlaceholderText=on_surface_variant,
ToolTipBase=surface_container_high…) pour les parties dessinées nativement.
L'ombre et l'arrondi vivent sur le QFrame#Panel enfant, et non sur la fenêtre elle-même : la fenêtre est transparente
et laisse une marge de 12px pour l'ombre. Il ne faut pas appliquer l'effet sur QMainWindow.
Déplacement et redimensionnement
Déplacer la fenêtre — mécanisme natif Qt6 (dans TopAppBar.mousePressEvent) :
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Double-clic sur TopAppBar — maximise/normalise.
Redimensionner par les bords — QWindow.startSystemResize natif. Dans MainWindow, un
filtre d'événements sur QApplication est installé : sur MouseButtonPress(Gauche) dans la zone ≤ EDGE_MARGIN
appelle _start_system_resize(edges) et retourne True ; sur MouseMove sans bouton
met à jour le curseur ; sur Leave réinitialise le curseur.
_resize_edges_for(pos) → masque de bits des bords (uniquement si la fenêtre est visible et non maximisée).
_cursor_for_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 via |.
Les mathématiques des bords utilisent leurs propres constantes de bits EDGE_*, le drapeau 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)
MdButton (filled / tonal, calque d'état + ondulation)
QPushButton, hauteur 40, rayon de pilule 20, police 14/Medium,padding 0 24px, minWidth = texte + 48.kind="filled"→ bgprimary/fgon_primary;kind="tonal"→ bgsecondary_container/fgon_secondary_container.- QSS :
background: transparent; color: {fg}— le fond est dessiné manuellement, le texte viasuper().paintEvent(). Q_PROPERTY hover/press/ripple(float). Calque d'état de couleur fg :alphaF = 0.08*hover + 0.04*press, clip par le rectangle arrondi.- Ripple :
mousePressEventenregistre le point (_ripple_origin),pressedlance_ripple_anim0→1 (DUR_RIPPLE) ; dans paint — ellipse à partir du point de rayonmax_r*ripple(max_r = distance jusqu'au coin le plus éloigné + 4),alphaF = 0.18*(1-ripple), clip par la pilule. Conserver la référence de l'animation dans un attribut.
MdSwitch (interrupteur MD3)
QWidget52×32, signaltoggled(bool)uniquement au clic ;setCheckedanime silencieusement.Q_PROPERTY knobPos(0..1)DUR_MED/EASE, plus hover/press.- Off : piste
surface_container_highest+ contour 2pxoutline; bouton 16px couleuroutline, centre x=16. On : pisteprimarysans contour ; bouton 24pxon_primary, centre x=36, à l'intérieur coche couleurprimary(draw_checkmark, pen 2px). Press : bouton 28px. - Piste/bouton sont interpolés avec
lerp_colorselon knobPos ; le contour disparaît à t→1 (alpha = 255*(1-t)) ; cercle de calque d'état de 40px autour du bouton.
MdSlider
QWidgethauteur 28,valueChanged(int). Piste 4px rayon 2 : inactifsurface_container_highest, actifprimary. Bouton — cercle 20pxprimary+ ombre légère (noir alpha 30, offset y+1). Calque d'état 40pxprimaryalphaF = 0.10*hover + 0.06*press.- Valeur à partir de x :
pad = 10(rayon du bouton),ratio = (x-pad)/(width-2*pad); pendant le drag — sans inertie, sinonfillest animéDUR_FAST.
MdCheckBox
QWidgethauteur 24, police 14, minWidth = 34 + texte. Boîte 18×18 rayon 2 at x=3.- Non cochée : contour 2px
on_surface_variant. Cochée : remplissageprimary+ cocheon_primary(pen 2px RoundCap/RoundJoin), la coche est « dessinée » progressivement (check0..1, deux lignes segmentées viadrawPath). - Calque d'état 36px :
on_surface0.06 (non cochée) /primary0.10 (cochée) * hover. boxScale(OutBack) compresse la boîte au press ;setChecked(checked, animate)émettoggled; clic dansmouseReleaseEventuniquement si le curseur est à l'intérieur du rect.
MdSegmentedControl / MdSegmentButton
- Conteneur hauteur 40, rayon de pilule 20, contour 1px
outline(dessiné dans paintEvent). - Segments de largeur égale (
addWidget(btn, 1)), hauteur 38, police 14/Medium ; margins (1,1,1,1), spacing 0. Séparateurs 1pxoutlineentre les voisins NON sélectionnés (dessinés dans paintEvent, coordonnées entières). - Indicateur —
QFrame#SegIndicator(bgsecondary_container, radius 19) derrière les boutons,QPropertyAnimation(geometry)DUR_MED; dansshowEvent—lower()+ géométrie sans animation. - Segment sélectionné : texte
on_secondary_container+ coche à gauche du texte (draw_checkmark) ; non sélectionné :on_surface. La couleur du texte est animée (colorpropriété QColor). Calque d'état du segment — pilule de la couleur du on-rôle.
DropGlyph / FileDropArea
- Glyphe 38×38 : flèche vers le bas + plateau, couleur
lerp(on_surface_variant, primary, level), haloprimaryalpha 0.10*level. FileDropArea(QFrame): minHeight 150,setAcceptDrops(True). Fondsurface_container_lowradius 16 ; pointillé 2pxoutline_variant(dashPattern [4,3.5]) ; au hover/drag — pointilléprimary+ overlayprimary_containeralpha ≈ 60glow+20press.dragEnterEvent(hasUrls)/dragLeaveEvent/dropEvent(uniquementisLocalFile()) →dropped(list[str]); clic →QFileDialog.getOpenFileNames. Toute la logique des signaux est inchangée.
WindowButton (boutons de fenêtre)
QPushButton40×40, radius 20,Q_PROPERTY hover/press,set_maximized(bool).- Glyphes
QPen1.8 RoundCap/RoundJoin, coordonnées entières : minimize — ligne horizontale ; maximize — carré 12×12 (contour), en mode agrandi — deux carrés superposés ; close — croix X. - Couleur du glyphe
lerp(on_surface_variant, on_surface, hover); close au hover — fonderror(cercle 40px) + glypheon_error. Les autres au hover — calque d'étaton_surface0.08. - Ordre à droite : minimize, maximize, close.
SidebarToggle
QPushButton40×40, calque d'étaton_surface0.08, glyphelerp(on_surface_variant, on_surface, hover).- Glyphe : cadre 16×16 radius 2 + séparateur ; panneau ouvert — séparateur à gauche + chevron
<(panel-left-close) ; réduit — séparateur à droite (panel-left-open). set_collapsed(bool); synchroniseMainWindow.toggle_sidebar.
TopAppBar
QWidget#TopAppBarhauteur 56, transparent par-dessus Panel ; margins (8,0,8,0), spacing 4.- À gauche
SidebarToggle, puis titre « MonProjet » 18/Mediumon_surface, stretch, à droiteWindowButtonminimize/maximize/close._max_btn+_sync_max_statevia eventFilter surWindowStateChange.startSystemMove+ double-clic pour maximiser.
NavButton / NavigationDrawer
NavButton(QPushButton#NavButton)hauteur 48, police 14, checkable ; icône 20×20 at x=16 dessinée avecQPainter(Material Symbols outlined) : dashboard (4 carrés), tasks (cercle+coche), edit (crayon), chat (bulle), folder, tune (3 lignes + boutons). Texte via QSSpadding-left: 48px.nextCheckState(...)— logique de sélection unique via l'attribut_groupstocké dans le tiroir de navigation. L'indicateur de sélection est unQFrame#PillIndicator(hauteur 28, radius 14, fondsecondary_container) positionné parQPropertyAnimation(geometry)DUR_MED. Le tiroir est unQFrame#NavDrawerde 220px de large avecsurfacecomme fond ; en mode réduit (collapsed=True), la largeur est de 56px (juste les icônes) ; le mode réduit/rétracté est animé avecQPropertyAnimation(minimumWidth).
6. build_stylesheet(theme)
Retourne un QSS (chaîne) avec l'ensemble du thème : Panel (background: surface, border-radius: 16px), cartes, séparateur, etc. Les couleurs sont injectées via f-string, les widgets natifs sont stylisés de force (QPushButton, QLineEdit, QListWidget, QScrollBar, QSlider — déjà remplacés, mais laissés en secours). QSS ne fait que les couleurs de fond/texte ; la géométrie fine, les calques d'état, les ombres et les icônes sont dans les paintEvent.
7. MainWindow (exigences exactes)
__init__ : thème, état max, pages ; _build_ui, _apply_palette, _sync_sidebar ; _fake_theme_transition (animation de niveau de thème pour la démo). toggle_theme : LIGHT↔DARK (_current_theme + _apply_palette + build_stylesheet + touch). _build_pages : empile les pages ; _switch_page(index) : appelle stack.setCurrentIndex(idx) + met à jour la sélection du tiroir.
toggle_sidebar : animation via QPropertyAnimation(minimumWidth) de NavDrawer DUR_MED + modifie la visibilité du séparateur show()/hide(). Au clic sur SidebarToggle : change _collapsed ET appelle toggle_sidebar.
Redimensionnement par les bords : eventFilter sur QApplication (voir §4).
_fake_theme_transition : QTimer.singleShot(60, toggle_theme) — la transition « réelle » est un cycle clair↔foncé toutes les 60ms pendant 1,5 s ; pour l'utilisateur, chaque cycle est déclenché manuellement.
_switch_page(index) : appelle setCurrentIndex + NavButton.setChecked ; l'animation de transition des pages est gérée par QStackedWidget (sans animation par défaut, mais le DUR_MED peut être appliqué via QVariantAnimation sur l'opacité).
8. Comportement de l'application de référence (tests)
- La fenêtre a un fond transparent avec une ombre (comme sur macOS/Windows).
- Réduire/agrandir/fermer fonctionne ; fermeture →
Qt.quit. - Double-clic sur la barre de titre → maximise ; icône de maximisation change.
- Déplacer la fenêtre par la barre de titre fonctionne (startSystemMove).
- Redimensionner par tous les bords/coins (startSystemResize) ; curseurs corrects.
- Les pages changent via le tiroir ; l'indicateur en pilule glisse (
QPropertyAnimation geometry). - Le tiroir se réduit/étend (220↔56px).
- Les interrupteurs cliquent ; les cases à cocher se cochent/décochent ; les curseurs glissent.
- Le chat a des messages (bouton + 5 réponses) ; les fichiers ont le glisser-déposer et l'icône animée.
- Le thème clair/foncé change tout (palette + QSS +
touch())._fake_theme_transitionmontre le cycle (optionnellement : retiré dans la version finale). - Aucune fuite de mémoire ; toutes les animations ont une référence stockée.
9. Erreurs fréquentes PyQt6 à éviter
QSSsur un widget parent n'est pas hérité par les enfants si ces derniers ont leur propresetStyleSheet. Utiliser des ancêtresQFrame#IDou une feuille de style unique au niveau de l'application.QTimer.singleShotcrée un minuteur qui n'est pas un QObject ; il peut être détruit accidentellement. StockerQTimer()dansselfsi un arrêt est nécessaire.QPropertyAnimationdoit être stocké ; sinon il est détruit après la première itération.QPainterdanspaintEvent: toujours commencer parpainter.setRenderHint, ne pas oublierpainter.end()(géré par le context manager).QColorne peut pas être animé directement ; utiliser desQ_PROPERTY float(r, g, b) ou une animation deQVariantavecQColor.- Matplotlib
FigureCanvasQTAggne fonctionne pas avecFramelessWindowHintcar QPainter natif attend un masque. Utiliser plutôt des remplacements Pixmap.
10. Organisation du code (upload unique)
app_template.py est un seul fichier (≈1800 lignes). Pour la production, diviser en modules. Le nom du fichier reste app_template.py ; l'agent ne doit PAS renommer main.py ou run.py. La signature doit permettre de copier-coller le fichier dans assets.
pip install PyQt6
python app_template.py
Construction : pyinstaller --windowed --icon=app.ico app_template.py. Le fichier .spec doit inclure les icônes supplémentaires et les assets.
11. Limites et pièges
QWebEngineViewne peut pas être utilisé à l'intérieur d'unFramelessWindowsous Windows en raison d'un bogue de rendu du GPU ; utiliserQGraphicsViewcomme alternative.QOpenGLWidgetpeut entrer en conflit avecWA_TranslucentBackgroundsur les pilotes NVIDIA ; utiliser un logiciel de rendu si nécessaire.- SVG dans
QSvgRenderernécessite unQPainteractif ; ne pas appelerrenderen dehors depaintEvent. QTimerpour l'animation : ne pas créer un nouveau minuteur à chaque frame ; utiliserQPropertyAnimationavec des propriétés.
12. Référence rapide (résumé pour l'agent)
| Composant | Clé | Comportement |
|---|---|---|
| Fenêtre | FramelessWindowHint+WA_TranslucentBackground | Déplacement natif, redimensionnement par les bords |
| Panel | QFrame#Panel | Ombre 24/4, radius 16, contient tout sauf la marge |
| Barre de titre | TopAppBar 56px | Toggle, titre, boutons de fenêtre, déplacement+dbl-max |
| Tiroir | NavDrawer 220/56px | QPropertyAnimation minimumWidth, indicateur en pilule |
| Bouton | MdButton filled/tonal | Calque d'état, ondulation, 40px, pill |
| Interrupteur | MdSwitch 52×32 | Cercle interpolé, coche On, calque d'état |
| Curseur | MdSlider h=28 | Piste bicolore, cercle 20px, calque d'état |
| Case à cocher | MdCheckBox h=24 | Coche progressivement, calque d'état, échelle |
| Segmenté | MdSegmentedControl h=40 | Boutons radio, indicateur animé, séparateurs |
| Dépôt | FileDropArea | Glisser-déposer de fichiers, clic pour parcourir, glyphe animé |
| Chat | QListWidget | 5 réponses, messages [ChatMessageWidget] |
| Thème | Theme dataclass | LIGHT/DARK switcher via QSS+palette |
| Animation | QPropertyAnimation + DUR_* | OutCubic/OutQuad pour tout |
13. Changements par rapport à PyQt5
Qt.Alignment— utiliserQt.AlignmentFlag.AlignLeft.Qt.MouseButton—Qt.MouseButton.LeftButton.connect—button.clicked.connect(fn)ne nécessite plus de convertisseur de type.QStringn'existe pas ; utiliserstr.QDesktopWidgetest supprimé ; utiliserQScreen.availableGeometry.QStyleFactory.create—Fusionest intégré.
14. Dépendances et compatibilité
- PyQt6 ≥ 6.5 (type hints, QPropertyAnimation).
- Zéro dépendance externe pour l'interface utilisateur (pas de PyQtGraph, QtCharts, ni modules supplémentaires).
- Matplotlib peut être installé (
%pip install matplotlib), mais son intégration dans une fenêtre sans cadre n'est pas testée. - PyInstaller est recommandé pour la diffusion.
- Testé sur Windows 10/11, Ubuntu 22.04, macOS 14. Les ombres natives macOS (NSWindow) ne sont pas utilisées ; la chute d'ombre est dessinée par Qt.
Fin de la spécification SKILL.md.