PyQt6 macOS 스타일 — 레퍼런스 앱 명세
아래는 assets/app_template.py를 동작별로 바이트 단위로 재현할 수 있는 완전한 명세입니다. macOS 스타일 데스크탑 애플리케이션을 만드는 스튜디오의 UI 디자이너 에이전트를 모델링하세요. 절제된 팔레트, 하나의 강조 색상, 부드러운 조명, 그리고 동작을 설명하는 데 필요한 정확한 애니메이션만 사용합니다.
핵심 규칙: Qt에서의 'macOS 스타일'은 네이티브 API가 아니라 수제로 재현한 미학입니다: 프레임리스 창 + 자체 QSS + 부드러움을 위한 QPropertyAnimation. 이렇게 하면 Windows, Linux, macOS에서 동일하게 보입니다. Native 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(호버/프레스),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 호버, 0.94 프레스),DUR_FAST.- x에서 값:
ratio = (x - pad) / (width - 2*pad),pad = 9.5. - 그리기: 트랙 높이 6 (배경
separatoralpha 26, 채움 —accent), 원 9.5px: 글로우 (accent, alpha 22 호버 / 34 프레스, radius +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.- 노란색/녹색 테두리 없음: 닫기 — 빨간색
#FF5F57(alpha 200 + 35·hover), 최소화/줌 — 중성rgba(text_secondary, 60 + 40·hover). - 글리프는
QPen으로RoundCap, 너비 2로 그려지며, 정수 좌표만 사용 (함정 참조):- 닫기 — 두 선으로 만든 엑스;
- 최소화 — 아래쪽 쉐브론:
(cx-5,cy-3)-(cx,cy+3)-(cx+5,cy-3); - 최대화 (확장되지 않음) — 위쪽 쉐브론:
(cx-5,cy+3)-(cx,cy-3)-(cx+5,cy+3); - 최대화 상태 (
_maximized) — 4개의 모서리 괄호를 만드는 8개의 선.
- 글리프 색상: 닫기용 — 흰색 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. 표시기 —QFrameradius 8, bgrgba(accent,40),geometry로 선택된 버튼으로 애니메이션 (DUR_MED). - 현재 페이지를 비활성 클릭 — 아무 작업 없음.
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, 화살표 및 페이지 영역 투명. - 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("Сбросить")버튼. -
Ввод — 필드:
- Имя, E-mail — 일반
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: pill-radius 17, 선행 돋보기 아이콘, 후행 X 아이콘, 비어 있지 않은 텍스트에서만 표시 (클릭 시 지우기). - Многострочный —
QTextEdit(radius 10, placeholder는palette.setColor(QPalette.ColorRole.PlaceholderText, ...)를 통해 — QSS 선택자QTextEdit[placeholderText]는 신뢰할 수 없음) + 오른쪽 문자 카운터. - 필드 아이콘은
_field_icon(18px: 돋보기 = 원+f-핸들; X = 원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(프레임리스; 흰 배경 방지: QSSbackground: transparent+viewport().setStyleSheet(...transparent)+ 내부QWidget투명). - 메시지는 `self._chat_messages: list[(text, m
[발췌됨]