PyQt6 を macOS 風に — リファレンスアプリケーションの仕様
以下は、assets/app_template.py を動作としてバイト単位で再現するための完全な仕様です。控えめなパレット、1つのアクセントカラー、ソフトな光、動作を説明するために必要な場所だけに配置されたアニメーションを持つ、macOSスタイルのデスクトップアプリケーションを制作するスタジオのインターフェースデザインエージェントのように振る舞ってください。
最重要ルール: Qtにおける「macOSスタイル」はネイティブAPIではなく、手作業で再現された美学です。フレームレスウィンドウ + カスタムQSS + QPropertyAnimation による滑らかさ。これにより、アプリケーションはWindows、Linux、macOSで同じように見えます。ネイティブスタイルのQtでは、丸み、半透明、アニメーションは実現できません。
リファレンス実装は assets/app_template.py(1ファイル)です。エージェントのタスクは、それをコピーして適応するか、このドキュメントに従ってゼロから再構築することです。
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 は、フィールド bg, surface, sidebar, text, text_secondary,
accent, separator_rgb (文字列 #000000/#FFFFFF — rgba() のベース), separator_alpha
(0..255), shadow_alpha (0..255) を持つ frozen dataclass です。
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 上のイベントフィルターが設定されており、以下の処理を行います:
- ウィンドウ端から
EDGE_MARGIN以内のゾーンでのMouseButtonPress(Left) 時に_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, 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: グロー (accent, alpha 22 hover / 34 press, 半径 +4)、影 (黒色 alpha 26)、 円形自体はtheme.bgで縁はseparatoralpha 90 (押下時は 255)、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 — 2本の線による十字;
- 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) — 4つの角括弧のような8本の線。
- グリフの色: close — 白色 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に垂直セパレータ、シェブロン<を(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、ウィンドウの半径。#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, 矢印とページ領域 — 透明。 - QSS背景を持つすべてのウィジェットに
setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)を設定します。
7. ページ (6個)
_page_shell(theme, title) (margins 28/24/28/24, spacing 16, タイトル 22/Bold) と
_add_card(theme, layout, title) (カード + タイトル 14/DemiBold + 影) を使用して構成されます。
-
Обзор (概要) — 「システム」カード (トグル: 自動起動 ✓, 通知 ✓, 省電力 ✗) と「表示」カード (スライダー: 明るさ=70, 音量=45)。スライダーの行 — 左にラベル, 右にコントロール、
control_stretch=1を使用 (stretchの落とし穴を参照)。 -
Задачи (タスク) — 4つの
QCheckBox。インジケーターは 18×18 (borderrgba(separator,120), hover →accent, checked → bgaccent); プログレスバー (12px, chunkaccent), カウンター 「完了 N / M」,QProgressBarのアニメーションはQVariantAnimation経由 (280ms); ボタンHoverButton("Сбросить")。(ボタンのラベルは「リセット」) -
Ввод (入力) — フィールド:
- 名前、メール — 通常の
QLineEdit(34px, radius 8, hover枠rgba(sep,140), focus → 枠accent+ tintrgba(accent,8), placeholdertext_secondary, selectionaccent+accent_fg)。 - パスワード —
_build_password_field: 末尾のアクション「目」 — アイコンはeye↔eye_slashに切り替わり、クリックでEchoMode.Password↔Normalを切り替え。 - 検索 —
_build_search_field: ピル半径 17、先頭に拡大鏡アイコン、末尾に バツ印アイコン (テキストが空でない場合のみ表示、クリックでクリア)。 - 複数行 —
QTextEdit(radius 10, placeholder は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, セグメント「UIサイズ」)、「動作」カード (トグル: 自動更新 ✓, アニメーション ✓)、 コンボボックス「言語」 (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
...