PyQt6 en estilo Material Design 3 — especificación de la aplicación de referencia
A continuación se presenta la especificación completa para reproducir assets/app_template.py
comportamiento por comportamiento. Modela al agente diseñador de interfaces de un estudio que crea
aplicaciones de escritorio MD3: paleta de roles (primary / on-primary / containers / surface /
outline), state layers, ripple, redondeos según la escala de formas, animaciones exactamente donde
explican lo que sucedió — y no más.
Regla principal: «estilo MD3» en Qt no es una API nativa, sino una estética recreada manualmente:
ventana sin marco + QSS propio + QPropertyAnimation para suavidad. Así la aplicación se ve
igual en Windows, Linux y macOS. El estilo nativo de Qt no proporciona redondeos,
transparencias, state layers ni animaciones.
La implementación de referencia es assets/app_template.py (un solo archivo). La tarea del agente: o copiarlo
y adaptarlo, o reconstruirlo desde cero exactamente según este documento.
1. Inicio rápido
pip install PyQt6
python app_template.py
Compilación para el usuario: pyinstaller --windowed --icon=app.ico app_template.py.
La aplicación se inicia así:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # único estilo que respeta QSS en todas partes
app.setFont(system_font(14, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. Estructura del archivo app_template.py (el orden es importante para la legibilidad)
- Importaciones:
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 (todos los widgets necesarios). - Tokens de diseño:
Theme(dataclass),LIGHT/DARK, constantes de radios/duraciones,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Helpers:
system_font,rgba,accent_fg,shadow_color,lerp_color,animate_property,draw_checkmark. - Widgets personalizados en orden de dependencias:
MdButton,MdSwitch,MdSlider,MdCheckBox,MdSegmentButton/MdSegmentedControl,DropGlyph/FileDropArea,WindowButton,SidebarToggle,TopAppBar,NavButton/NavigationDrawer. build_stylesheet(theme).MainWindow+main().
3. Tokens de diseño (valores exactos, roles MD3)
Theme — dataclass congelada, todos los colores son cadenas hex, roles 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, la sombra siempre es negra).
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)
Los pares «rol / on-rol» son obligatorios: el texto sobre primary es siempre on_primary, sobre
secondary_container es on_secondary_container, sobre primary_container es
on_primary_container. Nada de hardcodear «white»/«black. accent_fg(theme)se deja como helper de compatibilidad y devuelvetheme.on_primary`.
def rgba(hex_color: str, alpha_0_255: int) -> str: # QSS espera alfa 0..255, no 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:
- Formas:
RADIUS_WINDOW = 16,RADIUS_CARD = 12,RADIUS_MENU = 8,RADIUS_FIELD = 4,SIDEBAR_WIDTH = 220. Botones/píldoras — radio = altura/2; chips/segmentos — completo. EDGE_MARGIN = 12(coincide con el margen de la raíz bajo la sombra), máscaras de bits de bordesEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover),DUR_PRESS = 90,DUR_RELEASE = 200,DUR_MED = 250(páginas, drawer, interruptor, segmentos),DUR_RIPPLE = 450;EASE = OutCubic,EASE_PRESS = OutQuad.PAGES = ["Resumen", "Tareas", "Entrada", "Chat", "Archivos", "Ajustes"].REPLIES— 5 respuestas cortas del bot.SVG_EYE/SVG_EYE_SLASH— Material Symbolsvisibility/visibility_off(viewBox 0 0 24 24,fill="currentColor").
Tipografía: system_font(point_size=14, weight) — cadena Roboto → Google Sans →
Noto Sans → Segoe UI, setStyleStrategy(PreferAntialias). Tamaños: título de página 24,
título de tarjeta 16/Medium, texto de botón 14/Medium, cuerpo 14, secundario 12, hora 11.
Márgenes de la escala 4/8/12/16/24/32.
Sombras: ventana/panel — blurRadius 24, offset (0,4); tarjetas (elevation 1) —
blurRadius 8, offset (0,1); color shadow_color(theme).
State layers (sobre el contenedor, con el color del «on-rol»): hover 0.08, press 0.12 (es decir
0.08*hover + 0.04*press); en el deslizador hover 0.10 / press 0.16; en la casilla de verificación 0.06 (sin marcar)
/ 0.10 (marcada).
4. Estructura de la ventana
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) # para redimensionar por los bordes
Árbol (_build_ui): QMainWindow → root (QWidget, margins 12) →
panel (QFrame#Panel, drop-shadow) → TopAppBar (56px) + body (QHBoxLayout) →
NavigationDrawer (220px) + separator (1px, outline_variant) + QStackedWidget.
El panel recibe build_stylesheet(theme) — QSS se hereda hacia abajo. Adicionalmente
_apply_palette(theme) establece los roles de QPalette (Window/Base=surface, Text=on_surface,
Highlight=primary, HighlightedText=on_primary, PlaceholderText=on_surface_variant,
ToolTipBase=surface_container_high…) para las partes dibujadas de forma nativa.
La sombra y el redondeo viven en el QFrame#Panel hijo, no en la ventana misma: la ventana es transparente
y da un margen de 12px para la sombra. No se puede aplicar el efecto en QMainWindow.
Arrastre y redimensionamiento
Mover la ventana — mecanismo nativo de Qt6 (en TopAppBar.mousePressEvent):
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Doble clic en TopAppBar — maximizar/normal.
Cambiar el tamaño por los bordes — nativo QWindow.startSystemResize. En MainWindow se instala
un filtro de eventos en QApplication: en MouseButtonPress(Left) en la zona ≤ EDGE_MARGIN
llama a _start_system_resize(edges) y devuelve True; en MouseMove sin botones
actualiza el cursor; en Leave restablece el cursor.
_resize_edges_for(pos) → máscara de bits de bordes (solo si la ventana es visible y no está maximizada).
_cursor_for_edges: centro → Arrow; izquierda/derecha → SizeHor; arriba/abajo → SizeVer;
arriba+izquierda o abajo+derecha → SizeFDiag; si no → SizeBDiag.
Trampa de PyQt6: Qt.Edge no se puede convertir a int ni mezclar con int mediante |.
Las matemáticas de bordes usan sus propias constantes de bits EDGE_*, la bandera se construye así:
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 personalizados (parámetros exactos)
MdButton (filled / tonal, state layer + ripple)
QPushButton, altura 40, radio de píldora 20, fuente 14/Medium,padding 0 24px, minWidth = texto + 48.kind="filled"→ bgprimary/fgon_primary;kind="tonal"→ bgsecondary_container/fgon_secondary_container.- QSS:
background: transparent; color: {fg}— el fondo se dibuja manualmente, el texto mediantesuper().paintEvent(). Q_PROPERTY hover/press/ripple(float). State layer con color fg:alphaF = 0.08*hover + 0.04*press, recorte por rectángulo redondeado.- Ripple:
mousePressEventguarda el punto (_ripple_origin),pressedinicia_ripple_anim0→1 (DUR_RIPPLE); en paint — elipse desde el punto con radiomax_r*ripple(max_r = distancia hasta la esquina más lejana + 4),alphaF = 0.18*(1-ripple), recorte por píldora. Mantener la referencia a la animación en un atributo.
MdSwitch (interruptor MD3)
QWidget52×32, señaltoggled(bool)solo al hacer clic;setCheckedanima silenciosamente.Q_PROPERTY knobPos(0..1)DUR_MED/EASE, más hover/press.- Off: pista
surface_container_highest+ contorno 2pxoutline; perilla 16px de coloroutline, centro x=16. On: pistaprimarysin contorno; perilla 24pxon_primary, centro x=36, dentro una marca de verificación de colorprimary(draw_checkmark, pen 2px). Press: perilla 28px. - La pista/perilla se interpolan con
lerp_colorsegún knobPos; el contorno desaparece cuando t→1 (alpha = 255*(1-t)); círculo de state layer de 40px alrededor de la perilla.
MdSlider
QWidgetaltura 28,valueChanged(int). Pista 4px radio 2: inactivasurface_container_highest, activaprimary. Perilla — círculo 20pxprimary+ sombra ligera (negro alpha 30, offset y+1). State layer 40pxprimaryalphaF = 0.10*hover + 0.06*press.- Valor desde x:
pad = 10(radio de la perilla),ratio = (x-pad)/(width-2*pad); al arrastrar — sin inercia, si no,fillse anima conDUR_FAST.
MdCheckBox
QWidgetaltura 24, fuente 14, minWidth = 34 + texto. Caja 18×18 radio 2 en x=3.- Sin marcar: contorno 2px
on_surface_variant. Marcada: rellenoprimary+ marca de verificaciónon_primary(pen 2px RoundCap/RoundJoin), la marca se «dibuja» progresivamente (check0..1, dos líneas segmentadas mediantedrawPath). - State layer 36px:
on_surface0.06 (sin marcar) /primary0.10 (marcada) * hover. boxScale(OutBack) encoge la caja al presionar;setChecked(checked, animate)emitetoggled; clic enmouseReleaseEventsolo si el cursor está dentro del rect.
MdSegmentedControl / MdSegmentButton
- Contenedor altura 40, radio de píldora 20, contorno 1px
outline(se dibuja en paintEvent). - Segmentos de igual ancho (
addWidget(btn, 1)), altura 38, fuente 14/Medium; margins (1,1,1,1), spacing 0. Separadores 1pxoutlineentre vecinos NO seleccionados (se dibujan en paintEvent, coordenadas int). - Indicador —
QFrame#SegIndicator(bgsecondary_container, radio 19) detrás de los botones,QPropertyAnimation(geometry)DUR_MED; enshowEvent—lower()+ geometría sin animación. - Segmento seleccionado: texto
on_secondary_container+ marca de verificación a la izquierda del texto (draw_checkmark); no seleccionado:on_surface. El color del texto se anima (propiedadcolorQColor). State layer del segmento — píldora con el color del on-rol.
DropGlyph / FileDropArea
- Glyph 38×38: flecha hacia abajo + bandeja, color
lerp(on_surface_variant, primary, level), haloprimaryalpha 0.10*level. FileDropArea(QFrame): minHeight 150,setAcceptDrops(True). Fondosurface_container_lowradio 16; punteado 2pxoutline_variant(dashPattern [4,3.5]); al hacer hover/arrastrar — punteadoprimary+ overlayprimary_containeralpha ≈ 60glow+20press.dragEnterEvent(hasUrls)/dragLeaveEvent/dropEvent(soloisLocalFile()) →dropped(list[str]); clic →QFileDialog.getOpenFileNames. Toda la lógica de señales es inmutable.
WindowButton (botones de ventana)
QPushButton40×40, radio 20,Q_PROPERTY hover/press,set_maximized(bool).- Glifos
QPen1.8 RoundCap/RoundJoin, coordenadas int: minimizar — línea horizontal; maximizar — cuadrado 12×12 (contorno), al maximizar — dos cuadrados superpuestos; cerrar — cruz X. - Color del glifo
lerp(on_surface_variant, on_surface, hover); cerrar al hover — fondoerror(círculo 40px) + glifoon_error. Los demás al hover — state layeron_surface0.08. - Orden a la derecha: minimizar, maximizar, cerrar.
SidebarToggle
QPushButton40×40, state layeron_surface0.08, glifolerp(on_surface_variant, on_surface, hover).- Glifo: marco 16×16 radio 2 + separador; panel abierto — separador a la izquierda + chevron
<(panel-left-close); contraído — separador a la derecha (panel-left-open). set_collapsed(bool); sincronizaMainWindow.toggle_sidebar.
TopAppBar
QWidget#TopAppBaraltura 56, transparente sobre Panel; margins (8,0,8,0), spacing 4.- A la izquierda
SidebarToggle, luego título «MiProyecto» 18/Mediumon_surface, stretch, a la derechaWindowButtonminimizar/maximizar/cerrar._max_btn+_sync_max_statemediante eventFilter enWindowStateChange.startSystemMove+ doble clic para maximizar.
NavButton / NavigationDrawer
NavButton(QPushButton#NavButton)altura 48, fuente 14, checkable; icono 20×20 en x=16 dibujado conQPainter(Material Symbols outlined): dashboard (4 cuadrados), tasks (círculo+check), edit (lápiz), chat (burbuja), folder, tune (3 líneas + perillas). Texto mediante QSSpadding-left: 48px.nextCheckState— al hacer clic, llama aMainWindow.navigate_to(index). ElNavigationDrawer(QWidget#NavDrawer, ancho 220) contieneQVBoxLayoutcon botones, espaciado 8; el fondo se dibuja en paintEvent: state layer en hover/press del botón activo.- Indicador de página activa: tira vertical de 4px con radio 2, color
primary. EnNavButton.paintEventse dibuja un rectángulo relleno (x=0, y, w=4, h=48) cuandoisChecked.
6. Drawer de navegación (implementación detallada)
El drawer se encuentra en NavigationDrawer. Contiene NavButton para cada página en PAGES.
El ancho del drawer es SIDEBAR_WIDTH = 220. El drawer se puede contraer/expandir mediante
SidebarToggle o programáticamente con MainWindow.toggle_sidebar().
class NavigationDrawer(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedWidth(SIDEBAR_WIDTH)
layout = QVBoxLayout(self)
layout.setSpacing(8)
layout.setContentsMargins(0, 0, 0, 0)
for i, (icon, text) in enumerate(zip(NAV_ICONS, PAGES)):
btn = NavButton(icon, text, i)
btn.setCheckable(True)
btn.clicked.connect(lambda checked, idx=i: self._on_nav_clicked(idx))
layout.addWidget(btn)
layout.addStretch()
7. Animaciones (reglas generales)
- State layers: se animan con
QPropertyAnimationen las propiedadeshover/press. La duración del hover esDUR_FAST(140 ms), la de press esDUR_PRESS(90 ms). - Ripple: solo en
MdButton. DuraciónDUR_RIPPLE(450 ms). La animación se inicia enmousePressEventy se reproduce una vez. Si el usuario suelta el botón antes de que termine, la animación termina. - Transiciones de página: se anima el
QStackedWidgetconQPropertyAnimationen la propiedadgeometrydel widget actual. DuraciónDUR_MED(250 ms). - Drawer: animación de ancho del
NavigationDrawerusandoQPropertyAnimationenminimumWidth. DuraciónDUR_MED(250 ms). - Indicador segmentado: animación de geometría del
QFrame#SegIndicator. - Casilla de verificación: animación
boxScaleconOutBackeasing (ligero rebote). - Deslizador: animación del valor (relleno de la pista) con
DUR_FAST(140 ms).
8. Temas (claro/oscuro)
El tema se cambia con MainWindow.toggle_theme(). Llama a _apply_theme(theme) que:
- Actualiza
self._theme = theme. - Llama a
_apply_palette(theme)para actualizarQPalette. - Llama a
build_stylesheet(theme)para obtener el QSS actualizado. - Aplica el stylesheet al panel (
self._panel.setStyleSheet(qss)). - Actualiza el icono del botón de tema (luna/sol).
- Emite una señal
theme_changed(theme)para que los widgets hijos puedan reaccionar.
El tema se guarda en QSettings para persistencia entre sesiones.
9. Páginas de contenido
Cada página en QStackedWidget es un QWidget con un diseño específico:
- Resumen: tarjetas de información (estadísticas, gráficos simples dibujados con QPainter).
- Tareas: lista de tareas con casillas de verificación (
MdCheckbox). - Entrada: formulario con campos de texto, botones, interruptores.
- Chat: área de texto + entrada + botón de enviar. Respuestas simuladas desde
REPLIES. - Archivos:
FileDropAreapara arrastrar y soltar archivos. - Ajustes: controles para tema, fuente, etc.
10. Notas finales
- No uses QGraphicsDropShadowEffect directamente en la ventana principal; úsalo en el
QFrame#Panelpara obtener sombras correctas. - No mezcles estilos: usa siempre QSS para colores y QPropertyAnimation para animaciones.
- Prueba en todos los sistemas operativos: el comportamiento de
startSystemMoveystartSystemResizevaría ligeramente entre Windows, Linux y macOS. - Mantén la estructura de un solo archivo para facilitar la distribución y el prototipado rápido.
Apéndice: API de la aplicación de referencia
| Método | Descripción |
|---|---|
MainWindow.toggle_sidebar() | Contrae/expande el NavigationDrawer |
MainWindow.toggle_theme() | Cambia entre tema claro y oscuro |
MainWindow.navigate_to(index) | Cambia a la página en el índice dado |
MainWindow.current_page() | Devuelve el índice de la página actual |
MainWindow.set_page_title(text) | Establece el título en la TopAppBar |
Documento generado a partir de la especificación de código de assets/app_template.py.
Última actualización: Noviembre 2024.