Communitygithub.com

mawwki/pyqt6-md3-ui

Construa, redesenhe ou reformule uma GUI desktop em PyQt6/PySide6 para que pareça e se comporte como um aplicativo Material Design 3 (MD3) polido (janela sem moldura, botões de janela no estilo Material, painel de navegação com indicador tipo pílula, cartões elevados, botões tonais/preenchidos com ripple, switches/sliders/checkboxes/botões segmentados MD3, temas claro/escuro).

O que é pyqt6-md3-ui?

Esta skill define uma especificação completa para construir um aplicativo desktop PyQt6/PySide6 que replica fielmente a estética do Material Design 3 (MD3). Como o Qt nativo não suporta animações, camadas de estado (state layers), cantos arredondados ou sombras profundas do MD3, a implementação é feita manualmente usando uma janela sem moldura (frameless), QSS personalizado e QPropertyAnimation. O resultado é um aplicativo com aparência consistente no Windows, Linux e macOS. A skill fornece tokens de design exatos (cores como primary, on-primary, containers), constantes de raio e duração, animações para ripple, hover e press, e widgets customizados como MdButton, MdSwitch, MdSlider, MdCheckBox, MdSegmentedControl, NavigationDrawer e TopAppBar. Ela é útil para desenvolvedores que desejam criar interfaces desktop modernas e estilizadas, especialmente para solicitações como 'bonito/estiloso', 'no estilo Material/Material Design', 'interface moderna', 'janela sem moldura' ou que envolvam animações, ripple, navegação por painéis, alternadores e sliders. O ponto de partida é o arquivo `assets/app_template.py`, que serve como referência de implementação.

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

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

PyQt6 no estilo Material Design 3 — especificação do aplicativo de referência

Abaixo está a especificação completa para reproduzir o assets/app_template.py byte a byte em comportamento. Modele um agente designer de interfaces de um estúdio que cria aplicativos desktop MD3: paleta de papéis (primary / on-primary / containers / surface / outline), camadas de estado (state layers), ripple, cantos arredondados conforme a escala de formas, animações exatamente onde elas explicam o que aconteceu — e nada mais.

Regra principal: "estilo MD3" no Qt não é uma API nativa, mas uma estética recriada manualmente: janela sem moldura (frameless) + QSS personalizado + QPropertyAnimation para suavidade. Assim o aplicativo parece igual no Windows, Linux e macOS. O estilo nativo do Qt não oferece cantos arredondados, transparência, camadas de estado e animações.

A implementação de referência é assets/app_template.py (um único arquivo). Tarefa do agente: copiar e adaptar, ou recriar do zero exatamente conforme este documento.


1. Início rápido

pip install PyQt6
python app_template.py

Empacotamento para o usuário: pyinstaller --windowed --icon=app.ico app_template.py.

O aplicativo é iniciado assim:

def main() -> None:
    app = QApplication(sys.argv)
    app.setStyle("Fusion")                       # único estilo que respeita QSS em todos os lugares
    app.setFont(system_font(14, QFont.Weight.Normal))
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

2. Estrutura do arquivo app_template.py (a ordem é importante para legibilidade)

  1. 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 (todos os widgets necessários).
  2. Tokens de design: Theme (dataclass), LIGHT/DARK, constantes de raio/duração, REPLIES, SVG_EYE/SVG_EYE_SLASH, PAGES.
  3. Helpers: system_font, rgba, accent_fg, shadow_color, lerp_color, animate_property, draw_checkmark.
  4. Widgets customizados na ordem de dependências: MdButton, MdSwitch, MdSlider, MdCheckBox, MdSegmentButton/MdSegmentedControl, DropGlyph/FileDropArea, WindowButton, SidebarToggle, TopAppBar, NavButton/NavigationDrawer.
  5. build_stylesheet(theme).
  6. MainWindow + main().

3. Tokens de design (valores exatos, papéis MD3)

Theme — dataclass congelada, todas as cores são strings hex, papéis 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, a sombra é sempre preta).

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)

Os pares "papel / on-papel" são obrigatórios: texto sobre primary — sempre on_primary, sobre secondary_container — on_secondary_container, sobre primary_container — on_primary_container. Nada de hardcoded "white"/"black". accent_fg(theme) é mantido como helper de compatibilidade e retorna theme.on_primary.

def rgba(hex_color: str, alpha_0_255: int) -> str:   # QSS espera alpha 0..255, não 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. Botões/pílulas — raio = altura/2; chips/segmentos — total.
  • EDGE_MARGIN = 12 (coincide com o espaçamento da raiz para a sombra), máscaras de bits das bordas 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, toggle, segmentos), DUR_RIPPLE = 450; EASE = OutCubic, EASE_PRESS = OutQuad.
  • PAGES = ["Visão Geral", "Tarefas", "Entrada", "Chat", "Arquivos", "Configurações"].
  • REPLIES — 5 respostas curtas do bot.
  • SVG_EYE / SVG_EYE_SLASH — Material Symbols visibility / visibility_off (viewBox 0 0 24 24, fill="currentColor").

Tipografia: system_font(point_size=14, weight) — cadeia Roboto → Google Sans → Noto Sans → Segoe UI, setStyleStrategy(PreferAntialias). Tamanhos: título da página 24, título do cartão 16/Medium, texto do botão 14/Medium, corpo 14, secundário 12, hora 11. Espaçamentos da escala 4/8/12/16/24/32.

Sombras: janela/painel — blurRadius 24, offset (0,4); cartões (elevation 1) — blurRadius 8, offset (0,1); cor shadow_color(theme).

Camadas de estado (state layers) (sobre o container, na cor "on-papel"): hover 0.08, press 0.12 (ou seja, 0.08*hover + 0.04*press); no slider hover 0.10 / press 0.16; no checkbox 0.06 (desmarcado) / 0.10 (marcado).


4. Estrutura da janela

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 pelas bordas

Árvore (_build_ui): QMainWindow → root (QWidget, margins 12) → panel (QFrame#Panel, drop-shadow) → TopAppBar (56px) + body (QHBoxLayout) → NavigationDrawer (220px) + separator (1px, outline_variant) + QStackedWidget. O painel recebe build_stylesheet(theme) — QSS é herdado para baixo. Adicionalmente, _apply_palette(theme) define os papéis QPalette (Window/Base=surface, Text=on_surface, Highlight=primary, HighlightedText=on_primary, PlaceholderText=on_surface_variant, ToolTipBase=surface_container_high…) para as partes desenhadas nativamente.

A sombra e o arredondamento ficam no QFrame#Panel filho, não na janela: a janela é transparente e dá um espaçamento de 12px para a sombra. Não aplicar efeito no QMainWindow.

Arrastar e redimensionar

Mover a janela — mecanismo nativo do Qt6 (no TopAppBar.mousePressEvent):

handle = self._window.windowHandle()
if handle is not None:
    handle.startSystemMove()

Clique duplo no TopAppBar — maximizar/normal.

Redimensionar pelas bordas — nativo QWindow.startSystemResize. No MainWindow está instalado um filtro de eventos no QApplication: no MouseButtonPress(Left) na zona ≤ EDGE_MARGIN chama _start_system_resize(edges) e retorna True; no MouseMove sem botões atualiza o cursor; no Leave reseta o cursor.

_resize_edges_for(pos) → máscara de bits das bordas (apenas se a janela estiver visível e não maximizada). _cursor_for_edges: centro → Arrow; esquerda/direita → SizeHor; cima/baixo → SizeVer; cima+esquerda ou baixo+direita → SizeFDiag; senão → SizeBDiag.

Armadilha do PyQt6: Qt.Edge não pode ser convertido em int e não pode ser misturado com int via |. A matemática das bordas usa constantes de bits EDGE_*, a flag é montada assim:

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 customizados (parâmetros exatos)

MdButton (filled / tonal, state layer + ripple)

  • QPushButton, altura 40, raio pill 20, fonte 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} — o fundo é desenhado manualmente, o texto via super().paintEvent().
  • Q_PROPERTY hover/press/ripple (float). State layer na cor fg: alphaF = 0.08*hover + 0.04*press, clipping pelo retângulo arredondado.
  • Ripple: mousePressEvent armazena o ponto (_ripple_origin), pressed inicia _ripple_anim 0→1 (DUR_RIPPLE); no paint — elipse a partir do ponto com raio max_r*ripple (max_r = distância até o canto mais distante + 4), alphaF = 0.18*(1-ripple), clipping pela pílula. Manter referência à animação em um atributo.

MdSwitch (toggle MD3)

  • QWidget 52×32, sinal toggled(bool) apenas no clique; setChecked anima silenciosamente.
  • Q_PROPERTY knobPos (0..1) DUR_MED/EASE, mais hover/press.
  • Off: track surface_container_highest + contorno 2px outline; alça 16px na cor outline, centro x=16. On: track primary sem contorno; alça 24px on_primary, centro x=36, dentro uma marca de seleção na cor primary (draw_checkmark, pen 2px). Press: alça 28px.
  • Track/alça são interpolados com lerp_color por knobPos; o contorno desaparece quando t→1 (alpha = 255*(1-t)); círculo do state layer de 40px ao redor da alça.

MdSlider

  • QWidget altura 28, valueChanged(int). Track 4px raio 2: inativo surface_container_highest, ativo primary. Alça — círculo 20px primary + sombra leve (preto alpha 30, offset y+1). State layer 40px primary alphaF = 0.10*hover + 0.06*press.
  • Valor a partir de x: pad = 10 (raio da alça), ratio = (x-pad)/(width-2*pad); ao arrastar — sem inércia, senão o fill é animado com DUR_FAST.

MdCheckBox

  • QWidget altura 24, fonte 14, minWidth = 34 + texto. Caixa 18×18 raio 2 em x=3.
  • Desmarcado: contorno 2px on_surface_variant. Marcado: preenchimento primary + marca de seleção on_primary (pen 2px RoundCap/RoundJoin), a marca é "desenhada" progressivamente (check 0..1, duas linhas segmentadas via drawPath).
  • State layer 36px: on_surface 0.06 (desmarcado) / primary 0.10 (marcado) * hover.
  • boxScale (OutBack) comprime a caixa ao pressionar; setChecked(checked, animate) emite toggled; clique no mouseReleaseEvent apenas se o cursor estiver dentro do rect.

MdSegmentedControl / MdSegmentButton

  • Container altura 40, raio pill 20, contorno 1px outline (desenhado no paintEvent).
  • Segmentos de largura igual (addWidget(btn, 1)), altura 38, fonte 14/Medium; margins (1,1,1,1), spacing 0. Separadores 1px outline entre vizinhos NÃO selecionados (desenhados no paintEvent, coordenadas int).
  • Indicador — QFrame#SegIndicator (bg secondary_container, raio 19) atrás dos botões, QPropertyAnimation(geometry) DUR_MED; no showEvent — lower() + geometria sem animação.
  • Segmento selecionado: texto on_secondary_container + marca de seleção à esquerda do texto (draw_checkmark); não selecionado: on_surface. A cor do texto é animada (color propriedade QColor). State layer do segmento — pílula na cor on-papel.

DropGlyph / FileDropArea

  • Glyph 38×38: seta para baixo + bandeja, cor lerp(on_surface_variant, primary, level), halo primary alpha 0.10*level.
  • FileDropArea(QFrame): minHeight 150, setAcceptDrops(True). Fundo surface_container_low raio 16; pontilhado 2px outline_variant (dashPattern [4,3.5]); ao hover/drag — pontilhado primary + overlay primary_container alpha ≈ 60glow+20press.
  • dragEnterEvent(hasUrls) / dragLeaveEvent / dropEvent (apenas isLocalFile()) → dropped(list[str]); clique → QFileDialog.getOpenFileNames. Toda a lógica de sinais permanece inalterada.

WindowButton (botões da janela)

  • QPushButton 40×40, raio 20, Q_PROPERTY hover/press, set_maximized(bool).
  • Glifos QPen 1.8 RoundCap/RoundJoin, coordenadas int: minimize — linha horizontal; maximize — quadrado 12×12 (contorno), ao maximizar — dois quadrados sobrepostos; close — X.
  • Cor do glifo lerp(on_surface_variant, on_surface, hover); close no hover — fundo error (círculo 40px) + glifo on_error. Os demais no hover — state layer on_surface 0.08.
  • Ordem à direita: minimize, maximize, close.

SidebarToggle

  • QPushButton 40×40, state layer on_surface 0.08, glifo lerp(on_surface_variant, on_surface, hover).
  • Glifo: moldura 16×16 raio 2 + separador; painel aberto — separador à esquerda + chevron < (panel-left-close); recolhido — separador à direita (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.
  • À esquerda SidebarToggle, depois título "MeuProjeto" 18/Medium on_surface, stretch, à direita WindowButton minimize/maximize/close. _max_btn + _sync_max_state via eventFilter em WindowStateChange. startSystemMove + clique duplo maximizar.

NavButton / NavigationDrawer

  • NavButton(QPushButton#NavButton) altura 48, fonte 14, checkable; ícone 20×20 em x=16 desenhado com QPainter (Material Symbols outlined): dashboard (4 quadrados), tasks (círculo+check), edit (lápis), chat (balão), folder, tune (3 linhas + knobs). Texto via QSS padding-left: 48px.
  • nextCheckState

... (conteúdo truncado para tradução)

Habilidades Relacionadas