PyQt6 al estilo macOS – Especificación de la aplicación de referencia
A continuación se encuentra la especificación completa para reproducir assets/app_template.py al detalle. Modela a un agente diseñador de interfaces de un estudio que crea aplicaciones de escritorio con estilo macOS: paleta sobria, un color de acento, luz suave, animaciones justo donde explican lo que sucedió – y nada más.
La regla principal: «estilo macOS» 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 esquinas redondeadas, semitransparencias ni animaciones.
La implementación de referencia es assets/app_template.py (un solo archivo). La tarea del agente es: copiarla y adaptarla, o reconstruirla 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.icns app_template.py.
La aplicación se ejecuta así:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # único estilo que respeta QSS en todas partes
app.setFont(system_font(10, 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:
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 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. - Widgets personalizados en orden de dependencias:
HoverButton,ToggleSwitch,MacSlider,SegmentedControl,FileDropArea,TrafficLight,SidebarToggle,TitleBar,Sidebar. build_stylesheet(theme).MainWindow+main().
3. Tokens de diseño (valores exactos)
Theme – dataclass congelada con campos: bg, surface, sidebar, text, text_secondary,
accent, separator_rgb (cadena #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: el acento en esta aplicación no es azul. Es «invertido» – negro
#1D1D1F en el tema claro y claro #CFCFD4 en el oscuro (esto proporciona el mismo contraste que
System Blue, pero en una gama blanco y negro). Cualquier código que coloque texto sobre el acento
debe usar accent_fg(theme), y no valores fijos.
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 de bordesEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover/press),DUR_MED = 250(páginas, barra lateral, interruptor, segmentos),EASE = QEasingCurve.Type.OutCubic.PAGES = ["Resumen", "Tareas", "Entrada", "Chat", "Archivos", "Configuración"].REPLIES– 5 respuestas cortas del bot.SVG_EYE(Heroicons, viewBox 0 0 24 24,fill="currentColor") ySVG_EYE_SLASH(Bootstrap, viewBox 0 0 16 16) – cadenas multilínea.
Fuente: cadena de respaldo en system_font() – Inter → Segoe UI Variable Text →
.AppleSystemUIFont → SF Pro Text → Helvetica Neue → Manrope → Segoe UI;
más setStyleStrategy(QFont.StyleStrategy.PreferAntialias).
Márgenes de la escala 4/8/12/16/20/24/32. Sombras suaves: tarjetas – blurRadius 24,
offset (0,5), color shadow_color(theme) (negro con shadow_alpha).
4. Esqueleto 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 desde los bordes
Árbol (_build_ui): QMainWindow → root (QWidget) → panel (QFrame, objectName "Panel") →
TitleBar (40px) + body (QHBoxLayout) → Sidebar (196px) + separator (1px) + QStackedWidget.
El panel recibe build_stylesheet(theme) – QSS se hereda hacia abajo.
La sombra y el redondeo viven en el QFrame#Panel hijo, no en la ventana misma: la ventana es transparente
y da un margen para la sombra. Esto es fundamental – no se puede aplicar el efecto a QMainWindow.
Arrastre y redimensionamiento
Mover la ventana – mecanismo nativo de Qt6, sin matemática manual de desplazamiento:
def mousePressEvent(self, event): # en TitleBar
if event.button() == Qt.MouseButton.LeftButton:
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Cambiar el tamaño desde los bordes – también nativo QWindow.startSystemResize. En MainWindow
se instala un filtro de eventos en QApplication, que:
- en
MouseButtonPress(Left) en una zona ≤EDGE_MARGINdesde el borde de la ventana llama a_start_system_resize(edges)y devuelveTrue(el evento se consume); - en
MouseMovesin botones actualiza el cursor de la ventana; - en
Leaverestablece el cursor.
_resize_edges_for(pos) convierte la posición global en una máscara de bits de bordes; solo funciona
si la ventana es visible y no está maximizada. _cursor_for_edges(edges): centro → Arrow; izquierda/derecha
→ SizeHor; arriba/abajo → SizeVer; arriba+izquierda o abajo+derecha → SizeFDiag; de lo contrario → SizeBDiag.
Trampa de PyQt6: Qt.Edge no se puede convertir a int ni mezclar con int usando el operador |
(TypeError). Por lo tanto, toda la matemática de bordes se basa en constantes de bits propias EDGE_*, y la bandera para
startSystemResize 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)
HoverButton (botón con hover/press suave)
QPushButton,setFlat(True), altura 34, fuente 13/Medium,padding 0 18px, radiusRADIUS_CONTROL.- Color de texto en QSS:
color: {accent_fg(theme)}(¡no fijo «white»!). - Fondo – no QSS
:hover(instantáneo), sinoQ_PROPERTY alpha(float, 0..1): enpaintEventse pinta unQColorde acento consetAlphaF(alpha)+ rectángulo redondeado, luegosuper().paintEvent()dibuja el texto. Eventos: enter → 0.85, leave → 1.0, press → 0.7, release → 0.85/1.0. AnimaciónDUR_FAST,EASE.
ToggleSwitch (interruptor macOS)
QWidget42×24, señaltoggled(bool), manejador solo con el botón izquierdo.Q_PROPERTY knobPos(float 0..1) se anima conDUR_MED/EASE.- Dibujo: pista redondeada (radius = h/2), color interpolado entre
#B9B9BE(apagado) yaccent(encendido); perilla – círculoQColor(self._theme.bg), diámetroh-4, posiciónx_min = 2,x_max = width - knob_d - 2. - Trampa: la perilla se pinta con
theme.bg, no con blanco – en el tema oscuro una perilla blanca se fusiona con la pista blanca apagada. setCheckedprogramático solo anima, la señal NO se emite (se emiten enmousePressEvent).
MacSlider (slider macOS)
QWidgetaltura 28,Q_PROPERTY knobScale(1.0 → 1.12 hover, 0.94 press),DUR_FAST.- Valor a partir de x:
ratio = (x - pad) / (width - 2*pad),pad = 9.5. - Dibujo: pista altura 6 (fondo
separatorcon alpha 26, relleno –accent), círculo 9.5px: brillo (accent, alpha 22 hover / 34 press, radio +4), sombra (negro alpha 26), el círculo mismotheme.bgcon bordeseparatoralpha 90 (255 cuando pressed),penWidth 1.2.
SegmentedControl (segmentos «Compacto/Mediano/Grande»)
QWidgetaltura 30; botones altura 26, fuente 12,padding 0 16px, boxspacing 4, margins 4.- «Píldora» –
QFrame#SegIndicator(bgaccent, radius 7) detrás de los botones en el mismo layout, se mueve conQPropertyAnimation(geometry)DUR_MED/EASE. - Color de texto: para el seleccionado
accent_fg(theme), para los demástext_secondary. - En
showEventla píldora se coloca detrás (lower()) y se posiciona en la geometría del botón seleccionado.
TrafficLight (semáforo)
QPushButton22×22,Q_PROPERTY hover(0..1) animaciónDUR_MED.- Sin bordes de colores amarillo/verde: cerrar – rojo
#FF5F57(alpha 200 + 35·hover), minimizar/zoom – neutralrgba(text_secondary, 60 + 40·hover). - Los glifos se dibujan con
QPenconRoundCap, grosor 2, solo coordenadas enteras (ver trampas):- cerrar – cruz de dos líneas;
- minimizar – chevron hacia abajo:
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - maximizar (no expandido) – chevron hacia arriba:
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - maximizar en estado expandido (
_maximized) – 8 líneas de cuatro esquinas de corchetes.
- Color del glifo: para cerrar – blanco alpha 205+30·hover; para los demás – interpolación
text_secondary → textsegún hover.
SidebarToggle (botón de colapsar panel)
QPushButton22×22,hover0..1, hover-bgrgba(separator,26), glifo interpolatext_secondary → text.- Glifo del panel abierto (Lucide
panel-left-close): marcodrawRoundedRect(QRectF(cx-7, cy-7, 14, 14), 2, 2), separador vertical encx-2, chevron<desde(cx+4, cy-2)→(cx, cy)→(cx+4, cy+2). - Glifo del panel colapsado (Lucide
panel-right): marco + separador encx+2, sin chevron. set_collapsed(bool)cambia el glifo; el estado lo sincronizaMainWindow.toggle_sidebar.
Sidebar (navegación)
QWidgetsetFixedWidth(SIDEBAR_WIDTH), margins10,16,10,16, spacing 2.- Marca «MiProyecto» (13/Bold,
padding: 0 8px 14px 8px). - Nombres de páginas –
QPushButton#NavButtonaltura 30, fuente 13,checkable, inactivo –text_secondary, activo –text. Indicador –QFrameradius 8, bgrgba(accent,40), se anima segúngeometryhacia el botón seleccionado (DUR_MED). - Hacer clic en la página actual inactivo – no-op. En
showEventel indicador se coloca sin animación.
6. QSS general (build_stylesheet)
#Panel– bg del tema, radius de ventana.#TitleBar– transparente.#Sidebar– bg de la barra lateral.#Indicator– rgba(accent, 40), radius 8.QPushButton#NavButton– alineación a la izquierda, radius 8, padding-left 10;:checked–text;:hover– rgba(separator,26).QPushButton#ToolButton– color secundario, radius 6, hover resaltado.- Barras de desplazamiento (se agregan globalmente, para que el chat y las listas no sean «gruesos»):
vertical/horizontal 8px, handle radius 3
rgba(separator,60)(hover 120), min 30, flechas y páginas – transparentes. - Establecer
setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)en cualquier widget con fondos QSS.
7. Páginas (6 en total)
Se componen mediante _page_shell(theme, title) (margins 28/24/28/24, spacing 16, título
22/Bold) y _add_card(theme, layout, title) (tarjeta + título 14/DemiBold + shadow).
-
Resumen – tarjeta «Sistema» (interruptores: Inicio automático ✓, Notificaciones ✓, Ahorro de energía ✗) y «Pantalla» (sliders Brillo=70, Volumen=45). La línea del slider – etiqueta a la izquierda, control a la derecha con
control_stretch=1(ver trampa sobre stretch). -
Tareas – 4
QCheckBoxcon indicador 18×18 (bordergba(separator,120), hover →accent, checked → bgaccent); barra de progreso (12px, chunkaccent), contador «Completadas N de M», animaciónQProgressBarmedianteQVariantAnimation(280ms); botónHoverButton("Restablecer"). -
Entrada – campos:
- Nombre, Correo electrónico –
QLineEditnormal (34px, radius 8, hover bordergba(sep,140), focus → bordeaccent+ tintrgba(accent,8), placeholdertext_secondary, selectionaccent+accent_fg). - Contraseña –
_build_password_field: acción final «ojo» – icono cambiaeye↔eye_slash, clic cambiaEchoMode.Password↔Normal. - Búsqueda –
_build_search_field: pill-radius 17, icono inicial de lupa, icono final de cruz, visible solo cuando el texto no está vacío (clic – limpieza). - Multilínea –
QTextEdit(radius 10, placeholder mediantepalette.setColor(QPalette.ColorRole.PlaceholderText, ...)– el selector QSSQTextEdit[placeholderText]no es fiable) + contador de caracteres a la derecha. - Los iconos de los campos se dibujan en
_field_icon(18px: lupa = círculo+mango de f; cruz = círculorgba(text_secondary,110)+ «✕» de colortheme.bg) o se renderizan desde SVG (_svg_icon): antes de renderizar en SVG se reemplazacurrentColorportheme.text_secondary(ver trampa).
- Nombre, Correo electrónico –
-
Chat – ver sección 8.
-
Archivos – ver sección 9.
-
Configuración – tarjeta «Apariencia» (interruptor «Tema oscuro» →
_on_theme_toggled, segmento «Tamaño de interfaz»), «Comportamiento» (interruptores Actualizaciones automáticas ✓, Animaciones ✓), combobox «Idioma de interfaz» (QSS: camposurface+borde,QAbstractItemView–selection-background-color: accent; selection-color: accent_fg), slider «Velocidad de animaciones»=50.
8. Chat (página «Chat»)
- Contenedor –
QScrollAreatransparente (sin marco; para evitar fondo blanco: QSSbackground: transparent+viewport().setStyleSheet(...transparent)+QWidgetinterno transparente). - Los mensajes se almacenan en `self._chat_messages: list[(text, m
…(truncado para la traducción)