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)
- 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). - Tokens de design:
Theme(dataclass),LIGHT/DARK, constantes de raio/duração,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Helpers:
system_font,rgba,accent_fg,shadow_color,lerp_color,animate_property,draw_checkmark. - Widgets customizados na ordem de dependências:
MdButton,MdSwitch,MdSlider,MdCheckBox,MdSegmentButton/MdSegmentedControl,DropGlyph/FileDropArea,WindowButton,SidebarToggle,TopAppBar,NavButton/NavigationDrawer. build_stylesheet(theme).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 bordasEDGE_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 Symbolsvisibility/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"→ bgprimary/fgon_primary;kind="tonal"→ bgsecondary_container/fgon_secondary_container.- QSS:
background: transparent; color: {fg}— o fundo é desenhado manualmente, o texto viasuper().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:
mousePressEventarmazena o ponto (_ripple_origin),pressedinicia_ripple_anim0→1 (DUR_RIPPLE); no paint — elipse a partir do ponto com raiomax_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)
QWidget52×32, sinaltoggled(bool)apenas no clique;setCheckedanima silenciosamente.Q_PROPERTY knobPos(0..1)DUR_MED/EASE, mais hover/press.- Off: track
surface_container_highest+ contorno 2pxoutline; alça 16px na coroutline, centro x=16. On: trackprimarysem contorno; alça 24pxon_primary, centro x=36, dentro uma marca de seleção na corprimary(draw_checkmark, pen 2px). Press: alça 28px. - Track/alça são interpolados com
lerp_colorpor knobPos; o contorno desaparece quando t→1 (alpha = 255*(1-t)); círculo do state layer de 40px ao redor da alça.
MdSlider
QWidgetaltura 28,valueChanged(int). Track 4px raio 2: inativosurface_container_highest, ativoprimary. Alça — círculo 20pxprimary+ sombra leve (preto alpha 30, offset y+1). State layer 40pxprimaryalphaF = 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 ofillé animado comDUR_FAST.
MdCheckBox
QWidgetaltura 24, fonte 14, minWidth = 34 + texto. Caixa 18×18 raio 2 em x=3.- Desmarcado: contorno 2px
on_surface_variant. Marcado: preenchimentoprimary+ marca de seleçãoon_primary(pen 2px RoundCap/RoundJoin), a marca é "desenhada" progressivamente (check0..1, duas linhas segmentadas viadrawPath). - State layer 36px:
on_surface0.06 (desmarcado) /primary0.10 (marcado) * hover. boxScale(OutBack) comprime a caixa ao pressionar;setChecked(checked, animate)emitetoggled; clique nomouseReleaseEventapenas 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 1pxoutlineentre vizinhos NÃO selecionados (desenhados no paintEvent, coordenadas int). - Indicador —
QFrame#SegIndicator(bgsecondary_container, raio 19) atrás dos botões,QPropertyAnimation(geometry)DUR_MED; noshowEvent—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 (colorpropriedade 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), haloprimaryalpha 0.10*level. FileDropArea(QFrame): minHeight 150,setAcceptDrops(True). Fundosurface_container_lowraio 16; pontilhado 2pxoutline_variant(dashPattern [4,3.5]); ao hover/drag — pontilhadoprimary+ overlayprimary_containeralpha ≈ 60glow+20press.dragEnterEvent(hasUrls)/dragLeaveEvent/dropEvent(apenasisLocalFile()) →dropped(list[str]); clique →QFileDialog.getOpenFileNames. Toda a lógica de sinais permanece inalterada.
WindowButton (botões da janela)
QPushButton40×40, raio 20,Q_PROPERTY hover/press,set_maximized(bool).- Glifos
QPen1.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 — fundoerror(círculo 40px) + glifoon_error. Os demais no hover — state layeron_surface0.08. - Ordem à direita: minimize, maximize, close.
SidebarToggle
QPushButton40×40, state layeron_surface0.08, glifolerp(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); sincronizaMainWindow.toggle_sidebar.
TopAppBar
QWidget#TopAppBaraltura 56, transparente sobre Panel; margins (8,0,8,0), spacing 4.- À esquerda
SidebarToggle, depois título "MeuProjeto" 18/Mediumon_surface, stretch, à direitaWindowButtonminimize/maximize/close._max_btn+_sync_max_statevia eventFilter emWindowStateChange.startSystemMove+ clique duplo maximizar.
NavButton / NavigationDrawer
NavButton(QPushButton#NavButton)altura 48, fonte 14, checkable; ícone 20×20 em x=16 desenhado comQPainter(Material Symbols outlined): dashboard (4 quadrados), tasks (círculo+check), edit (lápis), chat (balão), folder, tune (3 linhas + knobs). Texto via QSSpadding-left: 48px.nextCheckState
... (conteúdo truncado para tradução)