Communitygithub.com

mawwki/pyqt6-md3-ui

Construye, rediseña o reestiliza una aplicación de escritorio GUI en PyQt6/PySide6 para que luzca y se sienta como una aplicación pulida de Material Design 3 (MD3) (ventana sin marco, botones de ventana Material, panel de navegación con indicador de píldora, tarjetas elevadas, botones tonales/rellenos con ripple, interruptores/deslizadores/casillas de verificación/botones segmentados MD3, temas claro/oscuro).

¿Qué es pyqt6-md3-ui?

Esta skill proporciona una especificación completa y un código de referencia (assets/app_template.py) para crear aplicaciones de escritorio con la estética de Material Design 3 (MD3) usando PyQt6/PySide6. Dado que Qt no ofrece soporte nativo para MD3, la skill detalla cómo recrear manualmente la apariencia: ventanas sin marco con sombras personalizadas, state layers, animaciones con QPropertyAnimation, y un sistema de temas claro/oscuro con colores MD3 precisos. Incluye una biblioteca de widgets personalizados como botones (rellenos/tonales), interruptores, deslizadores, casillas de verificación, controles segmentados, área de arrastre de archivos y un panel de navegación con iconos SVG. La skill está diseñada para agentes de IA (Claude Code, Cursor, Codex) que necesitan construir, rediseñar o modernizar interfaces de usuario de escritorio. Al seguir la especificación, el agente puede clonar y adaptar el archivo de referencia o reconstruir la aplicación desde cero, asegurando una apariencia consistente en Windows, Linux y macOS.

Compatible con~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/mawwki/pyqt6-ui-skills/tree/HEAD/pyqt6-md3-ui

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

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)

  1. 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).
  2. Tokens de diseño: Theme (dataclass), LIGHT/DARK, constantes de radios/duraciones, REPLIES, SVG_EYE/SVG_EYE_SLASH, PAGES.
  3. Helpers: system_font, rgba, accent_fg, shadow_color, lerp_color, animate_property, draw_checkmark.
  4. Widgets personalizados en orden de dependencias: MdButton, MdSwitch, MdSlider, MdCheckBox, MdSegmentButton/MdSegmentedControl, DropGlyph/FileDropArea, WindowButton, SidebarToggle, TopAppBar, NavButton/NavigationDrawer.
  5. build_stylesheet(theme).
  6. 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 bordes EDGE_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 Symbols visibility / 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" → bg primary/fg on_primary; kind="tonal" → bg secondary_container/fg on_secondary_container.
  • QSS: background: transparent; color: {fg} — el fondo se dibuja manualmente, el texto mediante super().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: mousePressEvent guarda el punto (_ripple_origin), pressed inicia _ripple_anim 0→1 (DUR_RIPPLE); en paint — elipse desde el punto con radio max_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)

  • QWidget 52×32, señal toggled(bool) solo al hacer clic; setChecked anima silenciosamente.
  • Q_PROPERTY knobPos (0..1) DUR_MED/EASE, más hover/press.
  • Off: pista surface_container_highest + contorno 2px outline; perilla 16px de color outline, centro x=16. On: pista primary sin contorno; perilla 24px on_primary, centro x=36, dentro una marca de verificación de color primary (draw_checkmark, pen 2px). Press: perilla 28px.
  • La pista/perilla se interpolan con lerp_color según knobPos; el contorno desaparece cuando t→1 (alpha = 255*(1-t)); círculo de state layer de 40px alrededor de la perilla.

MdSlider

  • QWidget altura 28, valueChanged(int). Pista 4px radio 2: inactiva surface_container_highest, activa primary. Perilla — círculo 20px primary + sombra ligera (negro alpha 30, offset y+1). State layer 40px primary alphaF = 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, fill se anima con DUR_FAST.

MdCheckBox

  • QWidget altura 24, fuente 14, minWidth = 34 + texto. Caja 18×18 radio 2 en x=3.
  • Sin marcar: contorno 2px on_surface_variant. Marcada: relleno primary + marca de verificación on_primary (pen 2px RoundCap/RoundJoin), la marca se «dibuja» progresivamente (check 0..1, dos líneas segmentadas mediante drawPath).
  • State layer 36px: on_surface 0.06 (sin marcar) / primary 0.10 (marcada) * hover.
  • boxScale (OutBack) encoge la caja al presionar; setChecked(checked, animate) emite toggled; clic en mouseReleaseEvent solo 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 1px outline entre vecinos NO seleccionados (se dibujan en paintEvent, coordenadas int).
  • Indicador — QFrame#SegIndicator (bg secondary_container, radio 19) detrás de los botones, QPropertyAnimation(geometry) DUR_MED; en showEvent — 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 (propiedad color QColor). 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), halo primary alpha 0.10*level.
  • FileDropArea(QFrame): minHeight 150, setAcceptDrops(True). Fondo surface_container_low radio 16; punteado 2px outline_variant (dashPattern [4,3.5]); al hacer hover/arrastrar — punteado primary + overlay primary_container alpha ≈ 60glow+20press.
  • dragEnterEvent(hasUrls) / dragLeaveEvent / dropEvent (solo isLocalFile()) → dropped(list[str]); clic → QFileDialog.getOpenFileNames. Toda la lógica de señales es inmutable.

WindowButton (botones de ventana)

  • QPushButton 40×40, radio 20, Q_PROPERTY hover/press, set_maximized(bool).
  • Glifos QPen 1.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 — fondo error (círculo 40px) + glifo on_error. Los demás al hover — state layer on_surface 0.08.
  • Orden a la derecha: minimizar, maximizar, cerrar.

SidebarToggle

  • QPushButton 40×40, state layer on_surface 0.08, glifo lerp(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); sincroniza MainWindow.toggle_sidebar.

TopAppBar

  • QWidget#TopAppBar altura 56, transparente sobre Panel; margins (8,0,8,0), spacing 4.
  • A la izquierda SidebarToggle, luego título «MiProyecto» 18/Medium on_surface, stretch, a la derecha WindowButton minimizar/maximizar/cerrar. _max_btn + _sync_max_state mediante eventFilter en WindowStateChange. startSystemMove + doble clic para maximizar.

NavButton / NavigationDrawer

  • NavButton(QPushButton#NavButton) altura 48, fuente 14, checkable; icono 20×20 en x=16 dibujado con QPainter (Material Symbols outlined): dashboard (4 cuadrados), tasks (círculo+check), edit (lápiz), chat (burbuja), folder, tune (3 líneas + perillas). Texto mediante QSS padding-left: 48px.
  • nextCheckState — al hacer clic, llama a MainWindow.navigate_to(index). El NavigationDrawer (QWidget#NavDrawer, ancho 220) contiene QVBoxLayout con 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. En NavButton.paintEvent se dibuja un rectángulo relleno (x=0, y, w=4, h=48) cuando isChecked.

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 QPropertyAnimation en las propiedades hover/press. La duración del hover es DUR_FAST (140 ms), la de press es DUR_PRESS (90 ms).
  • Ripple: solo en MdButton. Duración DUR_RIPPLE (450 ms). La animación se inicia en mousePressEvent y 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 QStackedWidget con QPropertyAnimation en la propiedad geometry del widget actual. Duración DUR_MED (250 ms).
  • Drawer: animación de ancho del NavigationDrawer usando QPropertyAnimation en minimumWidth. Duración DUR_MED (250 ms).
  • Indicador segmentado: animación de geometría del QFrame#SegIndicator.
  • Casilla de verificación: animación boxScale con OutBack easing (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:

  1. Actualiza self._theme = theme.
  2. Llama a _apply_palette(theme) para actualizar QPalette.
  3. Llama a build_stylesheet(theme) para obtener el QSS actualizado.
  4. Aplica el stylesheet al panel (self._panel.setStyleSheet(qss)).
  5. Actualiza el icono del botón de tema (luna/sol).
  6. 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: FileDropArea para 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#Panel para obtener sombras correctas.
  • No mezcles estilos: usa siempre QSS para colores y QPropertyAnimation para animaciones.
  • Prueba en todos los sistemas operativos: el comportamiento de startSystemMove y startSystemResize varí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étodoDescripció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.

Skills relacionados