PyQt6 머티리얼 디자인 3 스타일 — 레퍼런스 앱 명세
아래는 assets/app_template.py를 동작 방식까지 완벽하게 재현할 수 있는 전체 명세입니다.
MD3 데스크톱 앱을 만드는 스튜디오의 UI 디자이너 에이전트를 모델링하세요: 역할 기반 팔레트(primary / on-primary / containers / surface / outline), 상태 레이어, 리플, 모양 척도에 따른 둥글림, 변경 사항을 설명하는 데 필요한 애니메이션(그 이상은 불필요).
핵심 규칙: Qt에서의 'MD3 스타일'은 네이티브 API가 아닌, 수작업으로 재현한 미학입니다: 프레임리스 창 + 커스텀 QSS + 부드러움을 위한 QPropertyAnimation. 이렇게 하면 앱이 Windows, Linux, macOS에서 동일하게 보입니다. 네이티브 Qt 스타일은 둥근 모서리, 반투명, 상태 레이어 및 애니메이션을 제공하지 않습니다.
레퍼런스 구현체는 assets/app_template.py (단일 파일)입니다. 에이전트의 임무는 이를 복사하여 적용하거나, 이 문서에 따라 처음부터 다시 조립하는 것입니다.
1. 빠른 시작
pip install PyQt6
python app_template.py
사용자용 빌드: pyinstaller --windowed --icon=app.ico app_template.py.
앱 실행 방법:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # QSS를 모든 곳에서 존중하는 유일한 스타일
app.setFont(system_font(14, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. app_template.py 파일 구조 (가독성을 위해 순서가 중요)
- 임포트:
math,sys,time,dataclasses,pathlib.Path; QtCore (Qt,QEvent,QPropertyAnimation,QEasingCurve,QRect(F),QPointF,QParallelAnimationGroup,QVariantAnimation,QTimer,pyqtProperty,pyqtSignal); QtGui (QColor,QFont,QPainter,QPainterPath,QFontDatabase,QFontMetricsF,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,lerp_color,animate_property,draw_checkmark. - 의존성 순서에 따른 커스텀 위젯:
MdButton,MdSwitch,MdSlider,MdCheckBox,MdSegmentButton/MdSegmentedControl,DropGlyph/FileDropArea,WindowButton,SidebarToggle,TopAppBar,NavButton/NavigationDrawer. build_stylesheet(theme).MainWindow+main().
3. 디자인 토큰 (정확한 값, MD3 역할)
Theme — frozen dataclass, 모든 색상은 16진수 문자열, MD3 역할:
primary, on_primary, primary_container, on_primary_container,
secondary_container, on_secondary_container, surface, surface_container_low,
surface_container, surface_container_high, surface_container_highest, on_surface,
on_surface_variant, outline, outline_variant, error, on_error, error_container,
on_error_container, shadow_alpha (int 0..255, 그림자는 항상 검정).
LIGHT = Theme(primary="#6750A4", on_primary="#FFFFFF",
primary_container="#EADDFF", on_primary_container="#21005D",
secondary_container="#E8DEF8", on_secondary_container="#1D192B",
surface="#FEF7FF", surface_container_low="#F7F2FA",
surface_container="#F3EDF7", surface_container_high="#ECE6F0",
surface_container_highest="#E6E0E9", on_surface="#1D1B20",
on_surface_variant="#49454F", outline="#79747E", outline_variant="#CAC4D0",
error="#B3261E", on_error="#FFFFFF",
error_container="#F9DEDC", on_error_container="#410E0B", shadow_alpha=45)
DARK = Theme(primary="#D0BCFF", on_primary="#381E72",
primary_container="#4F378B", on_primary_container="#EADDFF",
secondary_container="#4A4458", on_secondary_container="#E8DEF8",
surface="#141218", surface_container_low="#1D1B20",
surface_container="#211F26", surface_container_high="#2B2930",
surface_container_highest="#36343B", on_surface="#E6E0E9",
on_surface_variant="#CAC4D0", outline="#938F99", outline_variant="#49454F",
error="#F2B8B5", on_error="#601410",
error_container="#8C1D18", on_error_container="#F9DEDC", shadow_alpha=120)
'역할/on-역할' 쌍은 필수입니다: primary 위의 텍스트는 항상 on_primary, secondary_container 위는 on_secondary_container, primary_container 위는 on_primary_container여야 합니다. 'white'/'black' 하드코딩은 절대 금지입니다. accent_fg(theme)는 호환성 헬퍼로 남겨두며 theme.on_primary를 반환합니다.
def rgba(hex_color: str, alpha_0_255: int) -> str: # QSS는 0..1이 아닌 0..255 알파 값을 기대함
c = QColor(hex_color)
return f"rgba({c.red()}, {c.green()}, {c.blue()}, {alpha_0_255})"
def shadow_color(theme: Theme) -> QColor:
c = QColor(0, 0, 0); c.setAlpha(theme.shadow_alpha); return c
상수:
- 모양:
RADIUS_WINDOW = 16,RADIUS_CARD = 12,RADIUS_MENU = 8,RADIUS_FIELD = 4,SIDEBAR_WIDTH = 220. 버튼/필 형태는 radius = height/2; 칩/세그먼트는 full. EDGE_MARGIN = 12(그림자 아래 루트 여백과 일치), 가장자리 비트마스크EDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8.DUR_FAST = 140(호버),DUR_PRESS = 90,DUR_RELEASE = 200,DUR_MED = 250(페이지, 드로어, 토글, 세그먼트),DUR_RIPPLE = 450;EASE = OutCubic,EASE_PRESS = OutQuad.PAGES = ["개요", "작업", "입력", "채팅", "파일", "설정"].REPLIES— 봇의 짧은 응답 5개.SVG_EYE/SVG_EYE_SLASH— Material Symbolsvisibility/visibility_off(viewBox 0 0 24 24,fill="currentColor").
타이포그래피: system_font(point_size=14, weight) — Roboto → Google Sans →
Noto Sans → Segoe UI 체인, setStyleStrategy(PreferAntialias). 크기: 페이지 제목 24,
카드 제목 16/Medium, 버튼 텍스트 14/Medium, 본문 14, 보조 12, 시간 11.
간격은 4/8/12/16/24/32 척도 사용.
그림자: 창/패널 — blurRadius 24, offset (0,4); 카드 (elevation 1) —
blurRadius 8, offset (0,1); 색상 shadow_color(theme).
상태 레이어 (컨테이너 위, 'on-역할' 색상): 호버 0.08, 누름 0.12 (즉,
0.08*hover + 0.04*press); 슬라이더는 호버 0.10 / 누름 0.16; 체크박스는 0.06 (unchecked)
/ 0.10 (checked).
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, margins 12) →
panel (QFrame#Panel, drop-shadow) → TopAppBar (56px) + body (QHBoxLayout) →
NavigationDrawer (220px) + separator (1px, outline_variant) + QStackedWidget.
패널은 build_stylesheet(theme)을 받습니다 — QSS는 하위로 상속됩니다. 추가로
_apply_palette(theme)가 QPalette 역할(Window/Base=surface, Text=on_surface,
Highlight=primary, HighlightedText=on_primary, PlaceholderText=on_surface_variant,
ToolTipBase=surface_container_high…)을 설정하여 네이티브로 그려지는 부분을 처리합니다.
그림자와 둥글림은 창 자체가 아닌 자식 QFrame#Panel에 적용됩니다: 창은 투명하며
그림자를 위해 12px 여백을 제공합니다. QMainWindow에 효과를 적용하면 안 됩니다.
드래그 앤 리사이즈
창 이동 — Qt6 네이티브 메커니즘 (TopAppBar.mousePressEvent에서):
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
TopAppBar 더블 클릭 — 최대화/일반.
가장자리 크기 조정 — 네이티브 QWindow.startSystemResize. MainWindow에
QApplication용 이벤트 필터 설치: MouseButtonPress(Left)가 EDGE_MARGIN 범위 내에서 발생하면
_start_system_resize(edges)를 호출하고 True 반환; 버튼 없는 MouseMove에서 커서 업데이트; Leave에서 커서 초기화.
_resize_edges_for(pos) → 가장자리 비트마스크 (창이 보이고 최대화되지 않은 경우에만).
_cursor_for_edges: 중앙 → Arrow; 왼쪽/오른쪽 → SizeHor; 위/아래 → SizeVer;
위+왼쪽 또는 아래+오른쪽 → SizeFDiag; 그 외 → SizeBDiag.
PyQt6 주의점: Qt.Edge는 int로 변환할 수 없으며 |를 통해 int와 혼합할 수 없습니다.
가장자리 계산은 자체 비트 상수 EDGE_*를 사용하며, 플래그는 다음과 같이 조립됩니다:
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. 커스텀 위젯 (정확한 매개변수)
MdButton (filled / tonal, 상태 레이어 + 리플)
QPushButton, 높이 40, 필 반경 20, 폰트 14/Medium,padding 0 24px, minWidth = text + 48.kind="filled"→ 배경primary/전경on_primary;kind="tonal"→ 배경secondary_container/전경on_secondary_container.- QSS:
background: transparent; color: {fg}— 배경은 수동으로 그림, 텍스트는super().paintEvent()를 통해. Q_PROPERTY hover/press/ripple(float). 전경 색상의 상태 레이어:alphaF = 0.08*hover + 0.04*press, 둥근 rect로 클리핑.- 리플:
mousePressEvent에서 지점 저장 (_ripple_origin),pressed가_ripple_anim0→1 (DUR_RIPPLE) 시작; paint에서 — 점에서 시작하는 타원, 반경max_r*ripple(max_r = 가장 먼 모서리까지의 거리 + 4),alphaF = 0.18*(1-ripple), 필로 클리핑. 애니메이션 참조는 속성에 유지.
MdSwitch (MD3 토글 스위치)
QWidget52×32, 클릭 시에만toggled(bool)시그널;setChecked는 무음 애니메이션.Q_PROPERTY knobPos(0..1)DUR_MED/EASE, 추가로 hover/press.- Off: 트랙
surface_container_highest+ 외곽선 2pxoutline; 손잡이 16px 색상outline, 중심 x=16. On: 트랙primary외곽선 없음; 손잡이 24pxon_primary, 중심 x=36, 내부 체크 표시 색상primary(draw_checkmark, pen 2px). Press: 손잡이 28px. - 트랙/손잡이는 knobPos에 따라
lerp_color로 보간; 외곽선은 t→1에서 사라짐 (alpha = 255*(1-t)); 손잡이 주변 40px 상태 레이어 원.
MdSlider
QWidget높이 28,valueChanged(int). 트랙 4px 반경 2: 비활성surface_container_highest, 활성primary. 손잡이 — 20px 원primary+ 약간의 그림자 (검정 alpha 30, offset y+1). 40px 상태 레이어primaryalphaF = 0.10*hover + 0.06*press.- x 값:
pad = 10(손잡이 반경),ratio = (x-pad)/(width-2*pad); 드래그 시 — 관성 없음, 그 외fill은DUR_FAST로 애니메이션.
MdCheckBox
QWidget높이 24, 폰트 14, minWidth = 34 + text. 박스 18×18 반경 2 at x=3.- Unchecked: 외곽선 2px
on_surface_variant. Checked: 채우기primary+ 체크 표시on_primary(pen 2px RoundCap/RoundJoin), 체크 표시는 점진적으로 그려짐 (check0..1,drawPath를 통한 두 개의 선분). - 36px 상태 레이어:
on_surface0.06 (unchecked) /primary0.10 (checked) * hover. boxScale(OutBack) 누를 때 박스 축소;setChecked(checked, animate)는toggled방출;mouseReleaseEvent의 클릭은 커서가 rect 내부에 있는 경우에만.
MdSegmentedControl / MdSegmentButton
- 컨테이너 높이 40, 필 반경 20, 외곽선 1px
outline(paintEvent에서 그림). - 세그먼트는 동일한 너비 (
addWidget(btn, 1)), 높이 38, 폰트 14/Medium; margins (1,1,1,1), spacing 0. 선택되지 않은 이웃 간 1pxoutline구분선 (paintEvent에서 그림, int 좌표). - 표시기 —
QFrame#SegIndicator(bgsecondary_container, radius 19) 버튼 뒤에,QPropertyAnimation(geometry)DUR_MED;showEvent에서 —lower()+ 애니메이션 없는 지오메트리. - 선택된 세그먼트: 텍스트
on_secondary_container+ 텍스트 왼쪽 체크 표시 (draw_checkmark); 선택되지 않음:on_surface. 텍스트 색상은 애니메이션됨 (colorQColor property). 세그먼트 상태 레이어 — on-역할 색상의 필.
DropGlyph / FileDropArea
- Glyph 38×38: 아래쪽 화살표 + 트레이, 색상
lerp(on_surface_variant, primary, level), 후광primaryalpha 0.10*level. FileDropArea(QFrame): minHeight 150,setAcceptDrops(True). 배경surface_container_lowradius 16; 점선 2pxoutline_variant(dashPattern [4,3.5]); hover/drag 시 — 점선primary+ 오버레이primary_containeralpha ≈ 60glow+20press.dragEnterEvent(hasUrls)/dragLeaveEvent/dropEvent(isLocalFile()인 경우에만) →dropped(list[str]); 클릭 →QFileDialog.getOpenFileNames. 모든 시그널 로직은 변경 불가.
WindowButton (창 버튼)
QPushButton40×40, 반경 20,Q_PROPERTY hover/press,set_maximized(bool).- 글리프
QPen1.8 RoundCap/RoundJoin, int 좌표: 최소화 — 가로선; 최대화 — 12×12 정사각형 (외곽선), 최대화 시 — 두 개의 겹쳐진 정사각형; 닫기 — X자. - 글리프 색상
lerp(on_surface_variant, on_surface, hover); 닫기 버튼은 hover 시 배경error(40px 원) + 글리프on_error. 나머지는 hover 시 상태 레이어on_surface0.08. - 오른쪽 순서: 최소화, 최대화, 닫기.
SidebarToggle
QPushButton40×40, 상태 레이어on_surface0.08, 글리프lerp(on_surface_variant, on_surface, hover).- 글리프: 16×16 프레임 반경 2 + 구분선; 열린 패널 — 왼쪽 구분선 + 쉐브론
<(panel-left-close); 접힘 — 오른쪽 구분선 (panel-left-open). set_collapsed(bool);MainWindow.toggle_sidebar와 동기화.
TopAppBar
QWidget#TopAppBar높이 56, Panel 위에 투명; margins (8,0,8,0), spacing 4.- 왼쪽
SidebarToggle, 그 다음 제목 "내프로젝트" 18/Mediumon_surface, stretch, 오른쪽WindowButton최소화/최대화/닫기._max_btn+_sync_max_state는WindowStateChange이벤트 필터를 통해.startSystemMove+ 더블 클릭 최대화.
NavButton / NavigationDrawer
NavButton(QPushButton#NavButton)높이 48, 폰트 14, checkable; 아이콘 20×20 at x=16QPainter로 그림 (Material Symbols outlined): 대시보드 (4개 사각형), 작업 (원+체크), 편집 (연필), 채팅 (말풍선), 폴더, 조정 (3개 선 + 손잡이). 텍스트는 QSS를 통해padding-left: 48px.nextCheckState— "선택됨"↔"선택 안 됨" 토글,toggled방출.NavigationDrawer(QFrame#NavDrawer)너비 220, 위쪽 여백 4;NavButton리스트 포함; 상위 창의set_page와 동기화.main.py에서 최소한의 예시.
6. build_stylesheet — 생성
QSS는 정적 로드가 아닌 파이썬에서 build_stylesheet(theme) -> str로 문자열 연결로 생성됩니다.
Theme 값에 의존하는 부분은 f-문자열에 포함됩니다. 최상위 위젯은 "#Panel"을 통해 QSS를 받고,
나머지는 클래스/이름을 통해 상속됩니다.
예시 패턴:
#Panel {
background: {surface};
border-radius: {RADIUS_WINDOW}px;
}
QPushButton#NavButton {{
background: transparent;
border: none; padding-left: 48px; text-align: left;
}}
모든 정적 상태는 QSS에 포함되며, 동적 상태 (호버, 누름, 리플)는 페인트 오버라이드에서 처리됩니다.
7. MainWindow — 상호작용
MainWindow는 _build_ui (위젯 트리 + 연결)와 _connect_signals를 관리합니다.
시그널:
page변경:NavButton.toggled→QStackedWidget.setCurrentIndex+ drawer (선택 사항) 닫기.theme전환: 버튼 →_apply_theme(Theme)→build_stylesheet+_apply_palette+ 모든 커스텀 위젯 강제 업데이트.Settings페이지의 토글 테마 버튼.toggle_sidebar:SidebarToggle.toggled→NavigationDrawer.setVisible+ 선택적 QPropertyAnimation(width).FileDropArea.dropped→ChatPage.add_file(assets/app_template.py참조).MdSlider.valueChanged/MdSwitch.toggled/MdCheckBox.toggled→InputPage위젯 상태 업데이트.MdSegmentButton.toggled→ 다른 세그먼트 선택 해제 (배타적 모드).
Settings 페이지: 테마 (isDark bool) + API 키 (QLineEdit, echoMode=EchoMode.Password,
눈 아이콘으로 토글).
ChatPage: QScrollArea (채팅 버블), 하단에 QLineEdit + MdButton (보내기);
sendMessage → 봇 응답 (REPLIES).
FilesPage: FileDropArea + 파일 리스트 + MdButton (선택 해제).
8. 생산 시 주의사항
- 절대 경로 없음: 모든 에셋 로드는
pathlib.Path(__file__).parent / "assets" / ...를 사용합니다. 폴백 없이 SVG를 불러오지 말고QSvgRenderer()를 직접 구성합니다. - int 좌표: 도형을 그릴 때
QPainterPath가 고대비 화면에서 흐려지는 것을 방지하기 위해0.5오프셋을 추가하여 int 좌표에 맞춥니다. - 애니메이션 메모리 누수 방지:
QPropertyAnimation인스턴스는 위젯 속성에 저장하거나QParallelAnimationGroup으로 그룹화해야 합니다. 다른 속성에 의해 참조되지 않은 로컬 변수는 시작 직후 가비지 컬렉션되어 애니메이션이 중단됩니다. QPropertyAnimation의 제너릭 getter/setter:Q_PROPERTY(float ...)는 pyqtProperty float을 기대합니다. 항상@pyqtProperty(float, ...)데코레이터를 사용합니다.- 이벤트 전파: 드래그/드롭 이벤트는
accept()를 호출해야 합니다. 리플은mousePressEvent에서super().mousePressEvent(event)를 호출해야 합니다. QFontDatabase: "Roboto"가 없으면 조용히 시스템 폰트로 대체됩니다.system_font헬퍼가 이 로직을 캡슐화합니다.- 타이포그래피 스케일: 제목 24, 부제목 16, 버튼 14, 본문 14, 보조 12, 시간 11 —
이상적인 값입니다. 제목은
QLabel에 24px 폰트를, 부제목은 16px를 사용합니다.
추가 지침:
- 모든 애니메이션은
QParallelAnimationGroup을 사용하여 동시에 시작할 수 있습니다. - 리플이 실행 중일 때 버튼을 다시 클릭하면 애니메이션이 다시 시작되어야 합니다 (재설정).
mdSlider는 drag가 아닌mouseMoveEvent에서 (버튼 누름 없이)wheelEvent에서도 값을 변경해야 합니다.build_stylesheet문자열에는 동적 상태를 제외한 모든 정적 QSS가 포함되어야 합니다._apply_palette는 부모Theme색상에서QPalette역할을 설정합니다.
이 문서는 머티리얼 디자인 3 가이드라인 (https://m3.material.io)에 따라 작성되었습니다.