PyQt6 在风格 macOS 上——参考应用规范
以下是完整的规范,可用于按行为逐字节地重现 assets/app_template.py。模拟一个来自工作室的界面设计代理,该工作室制作 macOS 风格的桌面应用程序:内敛的调色板、单一的强调色、柔和的光线、动画恰如其分地解释发生了什么——仅此而已。
黄金法则:Qt 中的“macOS 风格”不是原生 API,而是手动重现的美学:无边框窗口 + 自定义 QSS + QPropertyAnimation 实现流畅性。这样,应用程序在 Windows、Linux 和 macOS 上看起来都一样。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(这提供了与系统蓝相同的对比度,但采用黑白配色方案)。任何将文本放置在强调色背景上的代码必须使用 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(左键)事件中,如果位置在离窗口边缘 ≤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(带平滑悬停/按下的按钮)
QPushButton、setFlat(True)、高度 34、字体 13/Medium、padding 0 18px、圆角半径RADIUS_CONTROL。- QSS 中的文本:
color: {accent_fg(theme)}(不是硬编码的 "white"!)。 - 背景——不是 QSS
:hover(瞬间的),而是Q_PROPERTY alpha(float,0..1):在paintEvent中,使用setAlphaF(alpha)+ 圆角矩形绘制强调色QColor,然后调用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 悬停、0.94 按下)、DUR_FAST。- 从 x 获取值:
ratio = (x - pad) / (width - 2*pad)、pad = 9.5。 - 绘制:轨道高度 6(背景使用
separator的 alpha 26,填充使用accent)、圆形 9.5px:发光(accent、悬停时 alpha 22 / 按下时 34、半径 +4)、阴影(黑色 alpha 26)、圆形本身为theme.bg并带有边框separatoralpha 90(按下时为 255)、penWidth 1.2。
SegmentedControl(分段控件“紧凑/中等/大”)
QWidget高度 30;按钮高度 26、字体 12、padding 0 16px、盒子spacing 4、边距 4。- “药丸”——
QFrame#SegIndicator(背景accent、圆角半径 7)位于按钮后面的同一布局中,使用QPropertyAnimation(geometry)DUR_MED/EASE移动。 - 文本颜色:选中的按钮使用
accent_fg(theme),其余的按钮使用text_secondary。 - 在
showEvent中,将药丸向下放置(lower())并设置其几何形状为选中按钮的几何形状。
TrafficLight(交通灯按钮)
QPushButton22×22、Q_PROPERTY hover(0..1)动画DUR_MED。- 没有黄色/绿色的彩色边框:关闭——红色
#FF5F57(alpha 200 + 35·hover)、最小化/最大化——中性色rgba(text_secondary, 60 + 40·hover)。 - 使用
QPen和RoundCap绘制字形、宽度 2、仅整数坐标(参见陷阱):- 关闭——两条线组成的十字架;
- 最小化——向下的 V 形:
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - 最大化(非展开状态)——向上的 V 形:
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - 最大化在展开状态下(
_maximized)——8 条线组成四个角括号。
- 字形颜色:关闭——白色 alpha 205+30·hover;其余的——根据 hover 在
text_secondary → text之间插值。
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处绘制垂直分隔线、<形 V 从(cx+4, cy-2)→(cx, cy)→(cx+4, cy+2)。 - 折叠状态的字形(Lucide
panel-right):边框 + 在cx+2处的分隔线、无 V 形。 set_collapsed(bool)切换字形;状态与MainWindow.toggle_sidebar同步。
Sidebar(侧边栏导航)
QWidgetsetFixedWidth(SIDEBAR_WIDTH)、边距10,16,10,16、间距 2。- 品牌“我的项目”(13/Bold、
padding: 0 8px 14px 8px)。 - 页面名称——
QPushButton#NavButton高度 30、字体 13、checkable、非激活状态——text_secondary、激活状态——text。指示器——QFrame圆角半径 8、背景rgba(accent,40)、根据geometry动画到选中的按钮(DUR_MED)。 - 点击当前页面的非激活状态——无操作。在
showEvent中,指示器无动画放置。
6. 全局 QSS(build_stylesheet)
#Panel—— 主题背景、窗口圆角半径。#TitleBar—— 透明。#Sidebar—— 侧边栏背景。#Indicator—— rgba(accent, 40)、圆角半径 8。QPushButton#NavButton—— 左对齐、圆角半径 8、padding-left 10;:checked——text;:hover—— rgba(separator,26)。QPushButton#ToolButton—— 次要颜色、圆角半径 6、悬停时高亮。- 滚动条(全局添加,以确保聊天和列表不会显得“厚重”):垂直/水平 8px、手柄圆角半径 3
rgba(separator,60)(悬停时 120)、最小 30、箭头和页面——透明。 - 在任何具有 QSS 背景的控件上设置
setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)。
7. 页面(共 6 个)
通过 _page_shell(theme, title)(边距 28/24/28/24、间距 16、标题 22/Bold)和 _add_card(theme, layout, title)(卡片 + 标题 14/DemiBold + 阴影)进行组合。
-
概览 —— “系统”卡片(开关:自动启动 ✓、通知 ✓、省电模式 ✗)和“显示”卡片(滑块:亮度=70、音量=45)。滑块行 —— 左侧标签、右侧控件并设置
control_stretch=1(参见关于 stretch 的陷阱)。 -
任务 —— 4 个
QCheckBox,指示器 18×18(边框rgba(separator,120)、悬停时 →accent、选中时 → 背景accent);进度条(12px、块accent)、计数器“已完成 N 项,共 M 项”、通过QVariantAnimation实现QProgressBar动画(280ms);HoverButton("重置")按钮。 -
输入 —— 字段:
- 姓名、电子邮件 —— 常规
QLineEdit(34px、圆角半径 8、悬停边框rgba(sep,140)、焦点 → 边框accent+ 色调rgba(accent,8)、占位符text_secondary、选中状态accent+accent_fg)。 - 密码 ——
_build_password_field:尾部操作“眼睛”——图标在eye↔eye_slash之间切换、点击切换EchoMode.Password↔Normal。 - 搜索 ——
_build_search_field:药丸圆角半径 17、前导图标放大镜、尾部图标叉号、仅在文本非空时可见(点击 —— 清除)。 - 多行 ——
QTextEdit(圆角半径 10、通过palette.setColor(QPalette.ColorRole.PlaceholderText, ...)设置占位符——QSS 选择器QTextEdit[placeholderText]不可靠)+ 右侧字符计数器。 - 字段图标在
_field_icon中绘制(18px:放大镜 = 圆形 + 手柄;叉号 = 圆形rgba(text_secondary,110)+ 颜色为theme.bg的“✕”)或者从 SVG 渲染(_svg_icon):在渲染前,将 SVG 中的currentColor替换为theme.text_secondary(参见陷阱)。
- 姓名、电子邮件 —— 常规
-
聊天 —— 参见第 8 节。
-
文件 —— 参见第 9 节。
-
设置 —— “外观”卡片(开关“深色主题”→
_on_theme_toggled、分段“界面大小”)、“行为”卡片(开关:自动更新 ✓、动画 ✓)、组合框“界面语言”(QSS:字段surface+边框、QAbstractItemView——selection-background-color: accent; selection-color: accent_fg)、滑块“动画速度”=50。
8. 聊天(“聊天”页面)
- 容器 —— 透明的
QScrollArea(无框架;为避免白色背景:QSSbackground: transparent+viewport().setStyleSheet(...transparent)+ 内部QWidget透明)。 - 消息存储在
self._chat_messages: list[(text, m
[为翻译而截断]