PyQt6 em estilo macOS — especificação da aplicação de referência
Abaixo está a especificação completa para reproduzir assets/app_template.py
byte a byte em termos de comportamento. Modele um agente designer de interfaces de um estúdio que cria
aplicativos desktop no estilo macOS: paleta contida, uma cor de destaque, iluminação suave,
animações exatamente onde elas explicam o que aconteceu — e nada mais.
Regra principal: "estilo macOS" no Qt não é uma API nativa, mas uma estética recriada manualmente:
janela sem moldura + QSS personalizado + QPropertyAnimation para suavidade. Assim, o aplicativo
se parece igual no Windows, Linux e macOS. O estilo nativo do Qt não fornece cantos arredondados,
transparência nem animações.
A implementação de referência é assets/app_template.py (um arquivo). A tarefa do agente: copiá-lo
e adaptá-lo, ou recriá-lo do zero exatamente de acordo com este documento.
1. Início rápido
pip install PyQt6
python app_template.py
Compilação para o usuário: pyinstaller --windowed --icon=app.icns app_template.py.
O aplicativo é iniciado assim:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # único estilo que respeita QSS em qualquer lugar
app.setFont(system_font(10, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. Estrutura do arquivo app_template.py (ordem importante para legibilidade)
- Importações:
sys,time,dataclasses,pathlib.Path; QtCore (Qt,QEvent,QPropertyAnimation,QEasingCurve,QRect( F),QPointF,QParallelAnimationGroup,QVariantAnimation,QTimer,pyqtProperty,pyqtSignal); QtGui (QColor,QFont,QPainter,QFontDatabase,QPen,QPixmap,QIcon,QPalette); QtSvg (QSvgRenderer); QtWidgets (todos os widgets necessários). - Tokens de design:
Theme(dataclass),LIGHT/DARK, constantes de raios/durações,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Helpers:
system_font,rgba,accent_fg,shadow_color. - Widgets personalizados na ordem de dependências:
HoverButton,ToggleSwitch,MacSlider,SegmentedControl,FileDropArea,TrafficLight,SidebarToggle,TitleBar,Sidebar. build_stylesheet(theme).MainWindow+main().
3. Tokens de design (valores exatos)
Theme é uma dataclass congelada com campos: bg, surface, sidebar, text, text_secondary,
accent, separator_rgb (string #000000/#FFFFFF — base para rgba()), separator_alpha
(0..255), shadow_alpha (0..255).
LIGHT = Theme(bg="#F5F5F7", surface="#FFFFFF", sidebar="#ECECEF", text="#1D1D1F",
text_secondary="#6E6E73", accent="#1D1D1F",
separator_rgb="#000000", separator_alpha=20, shadow_alpha=55)
DARK = Theme(bg="#1E1E1E", surface="#2A2A2C", sidebar="#242426", text="#F5F5F7",
text_secondary="#98989D", accent="#CFCFD4",
separator_rgb="#FFFFFF", separator_alpha=20, shadow_alpha=150)
Importante: o destaque neste aplicativo não é azul. Ele é "invertido" — preto
#1D1D1F no tema claro e claro #CFCFD4 no escuro (isso fornece o mesmo contraste que
System Blue, mas em uma escala de cinza). Qualquer código que coloque texto sobre o destaque
deve usar accent_fg(theme), e não um valor fixo.
def accent_fg(theme: Theme) -> str:
color = QColor(theme.accent)
luminance = (0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) / 255
return "#1D1D1F" if luminance > 0.6 else "#FFFFFF"
def rgba(hex_color: str, alpha_0_255: int) -> str:
c = QColor(hex_color)
return f"rgba({c.red()}, {c.green()}, {c.blue()}, {alpha_0_255})"
Constantes:
RADIUS_WINDOW = 14,RADIUS_CARD = 12,RADIUS_CONTROL = 8,SIDEBAR_WIDTH = 196.EDGE_MARGIN = 6, máscaras de bits das bordasEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover/press),DUR_MED = 250(páginas, sidebar, toggle, segmentos),EASE = QEasingCurve.Type.OutCubic.PAGES = ["Visão Geral", "Tarefas", "Entrada", "Chat", "Arquivos", "Configurações"].REPLIES— 5 respostas curtas do bot.SVG_EYE(Heroicons, viewBox 0 0 24 24,fill="currentColor") eSVG_EYE_SLASH(Bootstrap, viewBox 0 0 16 16) — strings multilinha.
Fonte: cadeia de fallbacks em system_font() — Inter → Segoe UI Variable Text →
.AppleSystemUIFont → SF Pro Text → Helvetica Neue → Manrope → Segoe UI;
além de setStyleStrategy(QFont.StyleStrategy.PreferAntialias).
Margens da escala 4/8/12/16/20/24/32. Sombras suaves: cartões — blurRadius 24,
offset (0,5), cor shadow_color(theme) (preto com shadow_alpha).
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) → panel (QFrame, objectName "Panel") →
TitleBar (40px) + body (QHBoxLayout) → Sidebar (196px) + separator (1px) + QStackedWidget.
O painel recebe build_stylesheet(theme) — QSS é herdado para baixo.
Sombra e cantos arredondados ficam no filho QFrame#Panel, e não na janela: a janela é transparente
e dá espaço para a sombra. Isso é essencial — não se pode aplicar o efeito no QMainWindow.
Arrastar e redimensionar
Mover a janela — mecanismo nativo do Qt6, sem matemática manual de deslocamento:
def mousePressEvent(self, event): # no TitleBar
if event.button() == Qt.MouseButton.LeftButton:
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Redimensionar pelas bordas — também nativo QWindow.startSystemResize. No MainWindow
está instalado um filtro de eventos no QApplication, que:
- no
MouseButtonPress(Left) em zona ≤EDGE_MARGINda borda da janela chama_start_system_resize(edges)e retornaTrue(evento é consumido); - no
MouseMovesem botões atualiza o cursor da janela; - no
Leavereseta o cursor.
_resize_edges_for(pos) converte a posição global em uma máscara de bits das bordas; funciona apenas
se a janela estiver visível e não maximizada. _cursor_for_edges(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 nem misturado com int usando o operador |
(TypeError). Portanto, toda a matemática de bordas é feita com constantes de bit EDGE_*, e a flag para
startSystemResize é 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 personalizados (parâmetros exatos)
HoverButton (botão com hover/press suave)
QPushButton,setFlat(True), altura 34, fonte 13/Medium,padding 0 18px, radiusRADIUS_CONTROL.- Texto no QSS:
color: {accent_fg(theme)}(não fixo "white"!). - Fundo — não é QSS
:hover(instantâneo), masQ_PROPERTY alpha(float, 0..1): nopaintEventpinta-se umQColorde destaque comsetAlphaF(alpha)+ retângulo arredondado, depoissuper().paintEvent()desenha o texto. Eventos: enter → 0.85, leave → 1.0, press → 0.7, release → 0.85/1.0. AnimaçãoDUR_FAST,EASE.
ToggleSwitch (alternador macOS)
QWidget42×24, sinaltoggled(bool), manipulador apenas com botão esquerdo.Q_PROPERTY knobPos(float 0..1) animado comDUR_MED/EASE.- Desenho: trilha arredondada (radius = h/2), cor interpolada entre
#B9B9BE(desligado) eaccent(ligado); botão circular.QColor(self._theme.bg), diâmetroh-4, posiçãox_min = 2,x_max = width - knob_d - 2. - Armadilha: o botão é pintado com
theme.bg, e não com branco — no tema escuro, o botão branco se mistura com a trilha branca desligada. setCheckedprogramático apenas anima, NÃO emite sinal (os sinais são emitidos nomousePressEvent).
MacSlider (slider macOS)
QWidgetaltura 28,Q_PROPERTY knobScale(1.0 → 1.12 hover, 0.94 press),DUR_FAST.- Valor de x:
ratio = (x - pad) / (width - 2*pad),pad = 9.5. - Desenho: trilha altura 6 (fundo
separatorcom alpha 26, preenchimento —accent), círculo 9.5px: glow (accent, alpha 22 hover / 34 press, radius +4), sombra (preto alpha 26), círculo emtheme.bgcom bordaseparatoralpha 90 (255 quando pressionado),penWidth 1.2.
SegmentedControl (segmentos "Compacto/Médio/Grande")
QWidgetaltura 30; botões altura 26, fonte 12,padding 0 16px, boxspacing 4, margins 4.- "Pílula" —
QFrame#SegIndicator(bgaccent, radius 7) atrás dos botões no mesmo layout, movida comQPropertyAnimation(geometry)DUR_MED/EASE. - Cor do texto: no selecionado
accent_fg(theme), nos outrostext_secondary. - No
showEvent, abaixar a pílula (lower()) e definir para a geometria do botão selecionado.
TrafficLight (semáforo)
QPushButton22×22,Q_PROPERTY hover(0..1) animaçãoDUR_MED.- Sem bordas amarelo/verde: fechar — vermelho
#FF5F57(alpha 200 + 35·hover), minimizar/zoom — neutrorgba(text_secondary, 60 + 40·hover). - Glifos desenhados com
QPencomRoundCap, largura 2, apenas coordenadas inteiras (veja armadilhas):- fechar — 'X' de duas linhas;
- minimizar — seta para baixo:
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - maximizar (não expandido) — seta para cima:
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - maximizar no estado expandido (
_maximized) — 8 linhas de quatro cantos de colchetes.
- Cor do glifo: para fechar — branco alpha 205+30·hover; para os outros — interpolação
text_secondary → textde acordo com hover.
SidebarToggle (botão de recolher painel)
QPushButton22×22,hover0..1, hover-bgrgba(separator,26), glifo interpolatext_secondary → text.- Glifo do painel aberto (Lucide
panel-left-close): molduradrawRoundedRect(QRectF(cx-7, cy-7, 14, 14), 2, 2), separador vertical emcx-2, seta<de(cx+4, cy-2)→(cx, cy)→(cx+4, cy+2). - Glifo recolhido (Lucide
panel-right): moldura + separador emcx+2, sem seta. set_collapsed(bool)alterna o glifo; estado sincronizado comMainWindow.toggle_sidebar.
Sidebar (navegação)
QWidgetsetFixedWidth(SIDEBAR_WIDTH), margins10,16,10,16, spacing 2.- Marca "MeuProjeto" (13/Bold,
padding: 0 8px 14px 8px). - Nomes das páginas —
QPushButton#NavButtonaltura 30, fonte 13,checkable, inativo —text_secondary, ativo —text. Indicador —QFrameradius 8, bgrgba(accent,40), animado porgeometryaté o botão selecionado (DUR_MED). - Clique inativo na página atual — no-op. No
showEvento indicador é posicionado sem animação.
6. QSS geral (build_stylesheet)
#Panel— bg do tema, radius da janela.#TitleBar— transparente.#Sidebar— bg da sidebar.#Indicator— rgba(accent, 40), radius 8.QPushButton#NavButton— alinhamento à esquerda, radius 8, padding-left 10;:checked—text;:hover— rgba(separator,26).QPushButton#ToolButton— cor secundária, radius 6, destaque hover.- Scrollbars (adicionadas globalmente para chat e listas não serem "grossos"):
vertical/horizontal 8px, handle radius 3
rgba(separator,60)(hover 120), min 30, setas e páginas — transparentes. - Definir
setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)em qualquer widget com fundos QSS.
7. Páginas (6 no total)
Montadas via _page_shell(theme, title) (margins 28/24/28/24, spacing 16, título
22/Bold) e _add_card(theme, layout, title) (cartão + título 14/DemiBold + sombra).
-
Visão Geral — cartão "Sistema" (toggles: Inicialização Automática ✓, Notificações ✓, Economia de Energia ✗) e "Exibição" (sliders Brilho=70, Volume=45). Linha do slider — label à esquerda, controle à direita com
control_stretch=1(veja armadilha sobre stretch). -
Tarefas — 4
QCheckBoxcom indicador 18×18 (borderrgba(separator,120), hover →accent, checked → bgaccent); barra de progresso (12px, chunkaccent), contador "Concluído N de M", animaçãoQProgressBarviaQVariantAnimation(280ms); botãoHoverButton("Redefinir"). -
Entrada — campos:
- Nome, E-mail —
QLineEditcomum (34px, radius 8, hover bordargba(sep,140), focus → bordaaccent+ tintrgba(accent,8), placeholdertext_secondary, selectionaccent+accent_fg). - Senha —
_build_password_field: ação trailing "olho" — ícone mudaeye↔eye_slash, clique alternaEchoMode.Password↔Normal. - Pesquisa —
_build_search_field: pill-radius 17, ícone leading de lupa, ícone trailing de 'X', visível apenas quando o texto não está vazio (clique — limpa). - Multilinha —
QTextEdit(radius 10, placeholder viapalette.setColor(QPalette.ColorRole.PlaceholderText, ...)— seletor QSSQTextEdit[placeholderText]não é confiável) + contador de caracteres à direita. - Ícones dos campos desenhados em
_field_icon(18px: lupa = círculo + alça; 'X' = círculorgba(text_secondary,110)+ "✕" cortheme.bg) ou renderizados a partir de SVG (_svg_icon): antes de renderizar, substitui-se no SVGcurrentColorportheme.text_secondary(veja armadilha).
- Nome, E-mail —
-
Chat — veja seção 8.
-
Arquivos — veja seção 9.
-
Configurações — cartão "Aparência" (toggle "Tema Escuro" →
_on_theme_toggled, segmento "Tamanho da Interface"), "Comportamento" (toggles Atualizações Automáticas ✓, Animações ✓), combobox "Idioma da Interface" (QSS: camposurface+borda,QAbstractItemView—selection-background-color: accent; selection-color: accent_fg), slider "Velocidade das Animações"=50.
8. Chat (página "Chat")
- Container —
QScrollAreatransparente (sem moldura; para evitar fundo branco: QSSbackground: transparent+viewport().setStyleSheet(...transparent)+ widget internoQWidgettransparente). - Mensagens armazenadas em `self._chat_messages: list[(text, m) ... [truncado para tradução]