PyQt6 в стиле macOS — спецификация эталонного приложения
Ниже — полная спецификация, по которой можно воспроизвести assets/app_template.py
побайтно по поведению. Моделируй агента-дизайнера интерфейсов из студии, которая делает
macOS-style десктоп-приложения: сдержанная палитра, один акцентный цвет, мягкий свет,
анимации ровно там, где они объясняют, что произошло, — и не больше.
Главное правило: «стиль macOS» в Qt — это не нативный API, а воссозданная руками эстетика:
frameless-окно + собственный QSS + QPropertyAnimation для плавности. Так приложение
выглядит одинаково на Windows, Linux и macOS. Native style Qt не даёт скруглений,
полупрозрачности и анимаций.
Референс-реализация — assets/app_template.py (один файл). Задача агента: либо скопировать
и адаптировать его, либо пересобрать с нуля точно по этому документу.
1. Быстрый старт
pip install PyQt6
python app_template.py
Сборка для пользователя: pyinstaller --windowed --icon=app.icns app_template.py.
Приложение запускается так:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # единственный стиль, уважающий QSS везде
app.setFont(system_font(10, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. Структура файла app_template.py (порядок важен для читаемости)
- Импорты:
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 (все нужные виджеты). - Дизайн-токены:
Theme(dataclass),LIGHT/DARK, константы радиусов/длительностей,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES. - Хелперы:
system_font,rgba,accent_fg,shadow_color. - Кастомные виджеты в порядке зависимостей:
HoverButton,ToggleSwitch,MacSlider,SegmentedControl,FileDropArea,TrafficLight,SidebarToggle,TitleBar,Sidebar. build_stylesheet(theme).MainWindow+main().
3. Дизайн-токены (точные значения)
Theme — frozen dataclass с полями: bg, surface, sidebar, text, text_secondary,
accent, separator_rgb (строка #000000/#FFFFFF — база для 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)
Важно: акцент в этом приложении не синий. Он «инвертированный» — чёрный
#1D1D1F в светлой теме и светлый #CFCFD4 в тёмной (это даёт тот же контраст, что
System Blue, но в чёрно-белой гамме). Любой код, который кладёт текст поверх акцента,
обязан использовать accent_fg(theme), а не хардкод.
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})"
Константы:
RADIUS_WINDOW = 14,RADIUS_CARD = 12,RADIUS_CONTROL = 8,SIDEBAR_WIDTH = 196.EDGE_MARGIN = 6, бит-маски краёвEDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(hover/press),DUR_MED = 250(страницы, сайдбар, тумблер, сегменты),EASE = QEasingCurve.Type.OutCubic.PAGES = ["Обзор", "Задачи", "Ввод", "Чат", "Файлы", "Настройки"].REPLIES— 5 коротких ответов бота.SVG_EYE(Heroicons, viewBox 0 0 24 24,fill="currentColor") иSVG_EYE_SLASH(Bootstrap, viewBox 0 0 16 16) — многострочные строки.
Шрифт: цепочка фоллбэков в system_font() — Inter → Segoe UI Variable Text →
.AppleSystemUIFont → SF Pro Text → Helvetica Neue → Manrope → Segoe UI;
плюс setStyleStrategy(QFont.StyleStrategy.PreferAntialias).
Отступы из шкалы 4/8/12/16/20/24/32. Тени мягкие: карточки — blurRadius 24,
offset (0,5), цвет shadow_color(theme) (чёрный с shadow_alpha).
4. Каркас окна
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) # для ресайза за края
Дерево (_build_ui): QMainWindow → root (QWidget) → panel (QFrame, objectName "Panel") →
TitleBar (40px) + body (QHBoxLayout) → Sidebar (196px) + separator (1px) + QStackedWidget.
Панель получила build_stylesheet(theme) — QSS наследуется вниз.
Тень и скругление живут на дочернем QFrame#Panel, а не на самом окне: окно прозрачное
и даёт отступ под тень. Это принципиально — навешивать эффект на QMainWindow нельзя.
Перетаскивание и ресайз
Двигать окно — нативный механизм Qt6, никакой ручной математики смещения:
def mousePressEvent(self, event): # в TitleBar
if event.button() == Qt.MouseButton.LeftButton:
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
Менять размер за края — тоже нативный QWindow.startSystemResize. В MainWindow
установлен фильтр событий на QApplication, который:
- на
MouseButtonPress(Left) в зоне ≤EDGE_MARGINот края окна вызывает_start_system_resize(edges)и возвращаетTrue(событие съедается); - на
MouseMoveбез кнопок обновляет курсор окна; - на
Leaveсбрасывает курсор.
_resize_edges_for(pos) переводит глобальную позицию в бит-маску краёв; работает только
если окно видимо и не максимизировано. _cursor_for_edges(edges): центр → Arrow; лево/право
→ SizeHor; верх/низ → SizeVer; верх+лево или низ+право → SizeFDiag; иначе → SizeBDiag.
Капкан PyQt6: Qt.Edge нельзя превратить в int и нельзя смешивать с int оператором |
(TypeError). Поэтому вся математика краёв — на своих бит-константах EDGE_*, а флаг для
startSystemResize собирается так:
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. Кастомные виджеты (точные параметры)
HoverButton (кнопка с плавным hover/press)
QPushButton,setFlat(True), высота 34, шрифт 13/Medium,padding 0 18px, radiusRADIUS_CONTROL.- Текст в QSS:
color: {accent_fg(theme)}(не жёстко «white»!). - Подложка — не QSS
:hover(мгновенный), аQ_PROPERTY alpha(float, 0..1): вpaintEventкрасятся акцентныйQColorсsetAlphaF(alpha)+ скруглённый прямоугольник, потомsuper().paintEvent()рисует текст. События: enter → 0.85, leave → 1.0, press → 0.7, release → 0.85/1.0. АнимацияDUR_FAST,EASE.
ToggleSwitch (тумблер macOS)
QWidget42×24, сигналtoggled(bool), обработчик только по левой кнопке.Q_PROPERTY knobPos(float 0..1) анимируетсяDUR_MED/EASE.- Отрисовка: трек скруглённый (radius = h/2), цвет интерполируется между
#B9B9BE(выкл) иaccent(вкл); ручка — кругл.QColor(self._theme.bg), диаметрh-4, позицияx_min = 2,x_max = width - knob_d - 2. - Капкан: ручка красится
theme.bg, а не белым — в тёмной теме белая ручка сливается с белым выключенным треком. - Программный
setCheckedтолько анимирует, сигнал НЕ эмитит (эмитятся вmousePressEvent).
MacSlider (слайдер macOS)
QWidgetвысота 28,Q_PROPERTY knobScale(1.0 → 1.12 hover, 0.94 press),DUR_FAST.- Значение из x:
ratio = (x - pad) / (width - 2*pad),pad = 9.5. - Отрисовка: трек высота 6 (фон
separatorс alpha 26, заполнение —accent), кружок 9.5px: glow (accent, alpha 22 hover / 34 press, радиус +4), тень (чёрный alpha 26), сам кружокtheme.bgс ободкомseparatoralpha 90 (255 при pressed),penWidth 1.2.
SegmentedControl (сегменты «Компактный/Средний/Крупный»)
QWidgetвысота 30; кнопки высота 26, шрифт 12,padding 0 16px, boxspacing 4, margins 4.- «Пилюля» —
QFrame#SegIndicator(bgaccent, radius 7) позади кнопок в той же раскладке, перемещаетсяQPropertyAnimation(geometry)DUR_MED/EASE. - Цвет текста: у выбранной
accent_fg(theme), у остальныхtext_secondary. - В
showEventпилюлю опустить вниз (lower()) и выставить на геометрию выбранной кнопки.
TrafficLight (светофор)
QPushButton22×22,Q_PROPERTY hover(0..1) анимацияDUR_MED.- Без рамок-цветов жёлтого/зелёного: close — красный
#FF5F57(alpha 200 + 35·hover), minimize/zoom — нейтральныйrgba(text_secondary, 60 + 40·hover). - Глифы рисуются
QPenсRoundCap, шириной 2, только целочисленные координаты (см. капканы):- close — крестик из двух линий;
- minimize — шеврон вниз:
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - maximize (не развёрнуто) — шеврон вверх:
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - maximize в развёрнутом состоянии (
_maximized) — 8 линий четырёх уголков-скобок.
- Цвет глифа: для close — белый alpha 205+30·hover; для остальных — интерполяция
text_secondary → textпо hover.
SidebarToggle (кнопка сворачивания панели)
QPushButton22×22,hover0..1, hover-bgrgba(separator,26), глиф интерполируетtext_secondary → text.- Глиф открытой панели (Lucide
panel-left-close): рамкаdrawRoundedRect(QRectF(cx-7, cy-7, 14, 14), 2, 2), вертикальный разделитель наcx-2, шеврон<из(cx+4, cy-2)→(cx, cy)→(cx+4, cy+2). - Глиф свёрнутой (Lucide
panel-right): рамка + разделитель наcx+2, шеврона нет. set_collapsed(bool)переключает глиф; состояние синхронизируетMainWindow.toggle_sidebar.
Sidebar (навигация)
QWidgetsetFixedWidth(SIDEBAR_WIDTH), margins10,16,10,16, spacing 2.- Бренд «МойПроект» (13/Bold,
padding: 0 8px 14px 8px). - Названия страниц —
QPushButton#NavButtonвысоты 30, шрифт 13,checkable, неактивная —text_secondary, активная —text. Индикатор —QFrameрадиус 8, bgrgba(accent,40), анимируется поgeometryк выбранной кнопке (DUR_MED). - Неактивный клик по текущей странице — no-op. В
showEventиндикатор ставится без анимации.
6. Общий QSS (build_stylesheet)
#Panel— bg темы, radius окна.#TitleBar— прозрачный.#Sidebar— bg сайдбара.#Indicator— rgba(accent, 40), radius 8.QPushButton#NavButton— слева выравнивание, radius 8, padding-left 10;:checked—text;:hover— rgba(separator,26).QPushButton#ToolButton— вторичный цвет, radius 6, hover подсветка.- Скроллбары (добавляются глобально, чтобы чат и списки не были «толстыми»):
вертикаль/горизонталь 8px, handle radius 3
rgba(separator,60)(hover 120), min 30, стрелки и страницы — прозрачные. - Выставлять
setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)на любой виджет с QSS-фонами.
7. Страницы (6 штук)
Компонуется через _page_shell(theme, title) (margins 28/24/28/24, spacing 16, заголовок
22/Bold) и _add_card(theme, layout, title) (карточка + заголовок 14/DemiBold + shadow).
-
Обзор — карточка «Система» (тумблеры: Автозапуск ✓, Уведомления ✓, Энергосбережение ✗) и «Отображение» (слайдеры Яркость=70, Громкость=45). Строка слайдера — label слева, control справа с
control_stretch=1(см. капкан про stretch). -
Задачи — 4
QCheckBoxс индикатором 18×18 (borderrgba(separator,120), hover →accent, checked → bgaccent); прогресс-бар (12px, chunkaccent), счётчик «Выполнено N из M», анимацияQProgressBarчерезQVariantAnimation(280мс); кнопкаHoverButton("Сбросить"). -
Ввод — поля:
- Имя, E-mail — обычный
QLineEdit(34px, radius 8, hover рамкаrgba(sep,140), focus → рамкаaccent+ tintrgba(accent,8), placeholdertext_secondary, selectionaccent+accent_fg). - Пароль —
_build_password_field: trailing-действие «глаз» — иконка меняетсяeye↔eye_slash, клик переключаетEchoMode.Password↔Normal. - Поиск —
_build_search_field: pill-radius 17, leading-иконка увеличения, trailing-иконка крестика, видимая только при непустом тексте (клик — очистка). - Многострочный —
QTextEdit(radius 10, placeholder черезpalette.setColor(QPalette.ColorRole.PlaceholderText, ...)— QSS-селекторQTextEdit[placeholderText]ненадёжен) + счётчик символов справа. - Иконки полей рисуются в
_field_icon(18px: лупа = круг+f-ручка; крестик = кружокrgba(text_secondary,110)+ «✕» цветомtheme.bg) либо рендерятся из SVG (_svg_icon): перед рендером в SVG заменяетсяcurrentColorнаtheme.text_secondary(см. капкан).
- Имя, E-mail — обычный
-
Чат — см. раздел 8.
-
Файлы — см. раздел 9.
-
Настройки — карточка «Внешний вид» (тумблер «Тёмная тема» →
_on_theme_toggled, сегмент «Размер интерфейса»), «Поведение» (тумблеры Автообновления ✓, Анимации ✓), комбобокс «Язык интерфейса» (QSS: полеsurface+рамка,QAbstractItemView—selection-background-color: accent; selection-color: accent_fg), слайдер «Скорость анимаций»=50.
8. Чат (страница «Чат»)
- Контейнер — прозрачный
QScrollArea(frameless; чтобы не было белого фона: QSSbackground: transparent+viewport().setStyleSheet(...transparent)+ внутреннийQWidgetпрозрачный). - Сообщения хранятся в
self._chat_messages: list[(text, mine_bool, ts_str)]— переживают смену темы (пересборка страницы заново отрисовывает их). При первом показе — 2 стартовых сообщения. - Строка сообщения: QWidget с VBox: (пузырь + растяжка по стороне) + (время 11px
text_secondary,padding 0 4px, по той же стороне). ПузыриQFrame#BubblemaxWidth 430:- свои: bg
accent, текстaccent_fg, радиусы16, 4, 16, 4(tl,tr,bl,br); - чужие: bg
surface+ рамкаrgba(separator,70), текстtext, радиусы16, 16, 4, 16.
- свои: bg
- Вставка:
QQVBoxLayout(msgs)с последнимaddStretch(1); новые строки вставляются в индексcount()-1(перед растяжкой) — порядок правильный, растяжка внизу. - Ввод:
QLineEdit36px (сreturnPressed) +HoverButton("Отправить"). Над вводом — строка «печатает…» (12px,text_secondary), справа. - Отправка
_on_chat_send: непустой текст → очистить поле,(text, True, now)в историю, вставить пузырь,_schedule_chat_reply(), проскроллить вниз. - Ответ бота
_schedule_chat_reply: выставить флаг «печатает» + показать лейбл, выбрать ответ изREPLIESпо счётчику_reply_i, отложить700 + (i%3)*400мс черезQTimer.singleShot;_append_chat_reply—спрятать «печатает»,(text, False, now), вставить, проскроллить. Метка и флаг переживают пересборку темы. - Прокрутка вниз:
verticalScrollBar().setValue(bar.maximum())черезQTimer.singleShot(0)после сборки.
9. Файлы (страница «Файлы») — drag & drop
FileDropArea(QFrame)(см. класс): minHeight 150,setAcceptDrops(True), пунктирная рамка2px dashed rgba(separator,90), radius 14, фонrgba(separator,10); при перетаскивании (dragEnterEventсhasUrls()) — рамкаaccentalpha 200 и фонrgba(separator,26);dropEventизвлекаетu.toLocalFile()(толькоisLocalFile()) и эмититdropped(list[str]); клик (mouseReleaseEvent) —QFileDialog.getOpenFileNames.- Загрузка-зона: внутренний текст «Перетащите файлы сюда / или нажмите, чтобы выбрать».
- Под ней
QListWidget(110px,InternalMove+MoveAction— файлы можно переупорядочивать), счётчик «N файл/файла/файлов» (русская морфология: 1 → файл, 2-4 → файла, ≥5 → файлов). - Список принятых файлов живёт в
self._dropped_files: list[str](имена черезPath(f).name), переживает смену темы;drop.droppedконнектится к замыканию, которое кладёт новые имена в список и перерисовываетQListWidget. - Вторая карточка «Переупорядочивание» — демо-список из 5 файлов (
QListWidget150px,InternalMove) с подсказкой.
10. Смена темы и сворачивание панели
Смена темы (_on_theme_toggled)
Окно умеет только мгновенную пересборку — никакого fade прозрачности центрального виджета:
фокус на WA_TranslucentBackground просвечивает десктоп во время opacity-анимации.
def _on_theme_toggled(self, is_dark: bool) -> None:
self._stop_anims()
self.theme = DARK if is_dark else LIGHT
old_central = self.takeCentralWidget()
if old_central is not None:
old_central.deleteLater()
self._build_ui()
Состояние, которое переживает пересборку (держать атрибутами MainWindow): _current_page,
_sidebar_collapsed, _chat_messages, _chat_typing, _reply_i, _dropped_files.
_stop_anims() останавливает все висящие QPropertyAnimation (иначе Python может собрать
их объекты во время deleteLater → crash).
Сворачивание панели (toggle_sidebar / _animate_sidebar)
QParallelAnimationGroup анимирует minimumWidth и maximumWidth сайдбара (196 → 0) и
разделителя (1 → 0) одновременно, DUR_MED/EASE. Иконка SidebarToggle синхронизируется
через _title_bar.sidebar_toggle.set_collapsed(...).
Переход между страницами (switch_page)
QStackedWidget + QGraphicsOpacityEffect на обе страницы: prev 1→0, next 0→1 параллельно
(QParallelAnimationGroup, DUR_MED), по завершении эффекты снимаются
(setGraphicsEffect(None)) и ссылку на группу держим в _page_anim.
11. Капканы PyQt6 на Windows — проверяй первым делом
0xC0000409 / 0xC0000005 (fast-fail access violation) в PyQt6 + Python 3.12 на Windows — это обычно один из этих случаев. Смотри сюда сразу, как только падает нативный код:
QPen.setAlphaне существует. Это методQColor, а неQPen.pen.setAlpha(90)→ AttributeError внутриpaintEvent, вызванного из Qt, → аварийный выход0xC0000409. Правильно:color = QColor(...) color.setAlpha(90) pen = QPen(color)QPainter.drawLineс нецелочисленными координатами →0xC0000409. Всегда передавайint(илиQPoint):painter.drawLine(int(a), int(b), int(c), int(d)). При этомdrawEllipse(QPointF(...), r, r),drawRoundedRect(QRectF(...)),drawRectсо float безопасны.- Формат альфы в QSS. Qt QSS-
rgba()ожидает альфу 0..255. Для «8%» пиши alpha ≈ 20. Qt.Edgeне конвертируется вintи не смешивается сintчерез|(см. раздел 4).QSvgRendererигнорируетfill="currentColor"— рисует чёрным. До рендера замениcurrentColorна нужный hex строкой:svg.replace("currentColor", color).- QSS
:hoverмгновенный — плавность только черезQ_PROPERTY+QPropertyAnimation+ ручнойpaintEvent. WA_StyledBackgroundобязателен на виджете, которому задаёшь QSS-фон.- QBoxLayout стрейч: свободное место сначала уходит ненулевым stretch-факторам, потом
Expanding-виджетам.
addStretch(1)в строке со слайдером съедал всю ширину у holder слайдера. Правило строки label+control: если control должен растянуться —addWidget(control, stretch)и безaddStretch. - Держи ссылку на анимации (
self._anim = ...), иначе QPropertyAnimation собирается сборщиком до конца анимации. app.setStyle("Fusion")— иначе native-стиль местами игнорирует QSS.- Не передавай необработанные AttributeError «наружу» из paint — окно падает жёстко.
Любой новый рисующий код прогоняй через smoke-тест (ниже), не полагайся на try/except
вокруг
paintEvent, он не поможет — процесс умирает раньше.
12. Проверка после любых изменений (smoke-тест)
Перед сдачей прогони (агент может написать такой же):
- приложение строится и показывает окно без исключений;
- поочерёдно
switch_pageпо всем 6 страницам — без трейсбэков; - переключение темы в обе стороны (
_on_theme_toggled(True/False)) пересобирает окно,_current_pageсохраняется; - чат:
_on_chat_send()добавляет сообщение, поле очищается, черезtime.sleep(1.5+)+processEvents()приходит ответ, история переживает смену темы; - файлы: эмуляция drop через
area.dropped.emit([r'C:\tmp\a.pdf', r'C:\tmp\b.png'])добавляет имена в список, список переживает смену темы; - ресайз:
_resize_edges_forкорректно возвращает биты для краёв и углов, центр — 0; - иконки-глаза:
.pixmap(18,18).toImage()содержит непрозрачные пиксели (>20 шт. — рендер не пустой) в обеих темах.
Чек-лист «на глаз»: один акцентный цвет; контраст текста на акценте через accent_fg
проверен в обеих темах; все UI-анимации ≤ ~300мс; окно таскается за тулбар, ресайзится
за края, сворачивается/разворачивается; карточки не обрезаны (запас под тень); скроллбары
тонкие.
13. Проверка соответствия эталону
Если агент пересобирает приложение с нуля, сверяй по этому списку (это и есть «всё, как в приложении»):
- Frameless + translucent + Panel QFrame со скруглением и тенью.
- Светофор: красный close, нейтральные min/zoom, глиф-корнбрекеты при развороте.
- Перетаскивание за тулбар (
startSystemMove) и двойной клик — разворот. - Ресайз за все края и углы (
startSystemResize, EDGE_MARGIN 6) + курсоры. - Сворачивание сайдбара с анимацией ширины и сменой глифа
SidebarToggle. - Анимированный hover/press у кнопок (не QSS), анимированный тумблер, слайдер, сегменты.
- 6 страниц с перечисленным контентом; fade-переход между ними.
- Смена темы — мгновенная пересборка; история чата и принятые файлы сохраняются.
- Чат с пузырями, временем, «печатает…», автоответом бота.
- Drag & drop файлов в область + переупорядочивание списком.
- Акцент чёрный/светлый (не синий), текст на акценте — через
accent_fg. - Скроллбары тонкие, во всех страницах.
- Ни одного из капканов раздела 11 в коде.