Communitygithub.com

mawwki/pyqt6-md3-ui

PyQt6/PySide6 데스크톱 GUI 앱을 머티리얼 디자인 3(MD3) 스타일(프레임리스 창, MD3 버튼, 네비게이션 드로어, 토글, 라이트/다크 테마 등)로 빌드, 리디자인 또는 리스타일링합니다. 사용자가 '예쁜 UI', 'Material Design 3 스타일', '최신 인터페이스', '프레임리스 창', 또는 특정 레퍼런스 앱과 유사한 디자인을 요청할 때 사용하세요.

pyqt6-md3-ui란 무엇인가요?

이 스킬은 PyQt6/PySide6 기반 데스크톱 애플리케이션을 구글의 머티리얼 디자인 3(MD3) 가이드라인에 맞춰 완전히 리디자인할 수 있도록 설계된 정밀한 레퍼런스 구현체입니다. Qt 네이티브 스타일 시트(QSS)와 QPropertyAnimation을 사용하여 프레임리스 창, 머티리얼 역할 기반 컬러 시스템, 상태 레이어, 리플 애니메이션 등 MD3의 핵심 미학을 소프트웨어적으로 재현합니다. 이는 Qt가 기본적으로 제공하지 않는 둥근 모서리, 반투명 효과, 상태 레이어 및 부드러운 애니메이션을 구현하기 위함입니다. 지원되는 위젯으로는 MdButton(필드/토널), MdSwitch, MdSlider, MdCheckBox, MdSegmentedControl, NavigationDrawer, TopAppBar, WindowButton, FileDropArea 등이 포함되며, 라이트/다크 테마 전환과 정확한 디자인 토큰(색상, 반경, 지속 시간 등)을 제공합니다. Claude Code, Cursor, 또는 Codex와 같은 AI 에이전트가 이 문서를 기반으로 사용자 요청에 따라 앱의 외형을 완전히 바꾸거나 처음부터 새로 빌드할 수 있습니다.

지원 대상~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/mawwki/pyqt6-ui-skills/tree/HEAD/pyqt6-md3-ui

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

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 파일 구조 (가독성을 위해 순서가 중요)

  1. 임포트: 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 (필요한 모든 위젯).
  2. 디자인 토큰: Theme (dataclass), LIGHT/DARK, 반경/지속 시간 상수, REPLIES, SVG_EYE/SVG_EYE_SLASH, PAGES.
  3. 헬퍼: system_font, rgba, accent_fg, shadow_color, lerp_color, animate_property, draw_checkmark.
  4. 의존성 순서에 따른 커스텀 위젯: MdButton, MdSwitch, MdSlider, MdCheckBox, MdSegmentButton/MdSegmentedControl, DropGlyph/FileDropArea, WindowButton, SidebarToggle, TopAppBar, NavButton/NavigationDrawer.
  5. build_stylesheet(theme).
  6. 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 Symbols visibility / 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_anim 0→1 (DUR_RIPPLE) 시작; paint에서 — 점에서 시작하는 타원, 반경 max_r*ripple (max_r = 가장 먼 모서리까지의 거리 + 4), alphaF = 0.18*(1-ripple), 필로 클리핑. 애니메이션 참조는 속성에 유지.

MdSwitch (MD3 토글 스위치)

  • QWidget 52×32, 클릭 시에만 toggled(bool) 시그널; setChecked는 무음 애니메이션.
  • Q_PROPERTY knobPos (0..1) DUR_MED/EASE, 추가로 hover/press.
  • Off: 트랙 surface_container_highest + 외곽선 2px outline; 손잡이 16px 색상 outline, 중심 x=16. On: 트랙 primary 외곽선 없음; 손잡이 24px on_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 상태 레이어 primary alphaF = 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), 체크 표시는 점진적으로 그려짐 (check 0..1, drawPath를 통한 두 개의 선분).
  • 36px 상태 레이어: on_surface 0.06 (unchecked) / primary 0.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. 선택되지 않은 이웃 간 1px outline 구분선 (paintEvent에서 그림, int 좌표).
  • 표시기 — QFrame#SegIndicator (bg secondary_container, radius 19) 버튼 뒤에, QPropertyAnimation(geometry) DUR_MED; showEvent에서 — lower() + 애니메이션 없는 지오메트리.
  • 선택된 세그먼트: 텍스트 on_secondary_container + 텍스트 왼쪽 체크 표시 (draw_checkmark); 선택되지 않음: on_surface. 텍스트 색상은 애니메이션됨 (color QColor property). 세그먼트 상태 레이어 — on-역할 색상의 필.

DropGlyph / FileDropArea

  • Glyph 38×38: 아래쪽 화살표 + 트레이, 색상 lerp(on_surface_variant, primary, level), 후광 primary alpha 0.10*level.
  • FileDropArea(QFrame): minHeight 150, setAcceptDrops(True). 배경 surface_container_low radius 16; 점선 2px outline_variant (dashPattern [4,3.5]); hover/drag 시 — 점선 primary + 오버레이 primary_container alpha ≈ 60glow+20press.
  • dragEnterEvent(hasUrls) / dragLeaveEvent / dropEvent (isLocalFile()인 경우에만) → dropped(list[str]); 클릭 → QFileDialog.getOpenFileNames. 모든 시그널 로직은 변경 불가.

WindowButton (창 버튼)

  • QPushButton 40×40, 반경 20, Q_PROPERTY hover/press, set_maximized(bool).
  • 글리프 QPen 1.8 RoundCap/RoundJoin, int 좌표: 최소화 — 가로선; 최대화 — 12×12 정사각형 (외곽선), 최대화 시 — 두 개의 겹쳐진 정사각형; 닫기 — X자.
  • 글리프 색상 lerp(on_surface_variant, on_surface, hover); 닫기 버튼은 hover 시 배경 error (40px 원) + 글리프 on_error. 나머지는 hover 시 상태 레이어 on_surface 0.08.
  • 오른쪽 순서: 최소화, 최대화, 닫기.

SidebarToggle

  • QPushButton 40×40, 상태 레이어 on_surface 0.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/Medium on_surface, stretch, 오른쪽 WindowButton 최소화/최대화/닫기. _max_btn + _sync_max_state는 WindowStateChange 이벤트 필터를 통해. startSystemMove + 더블 클릭 최대화.

NavButton / NavigationDrawer

  • NavButton(QPushButton#NavButton) 높이 48, 폰트 14, checkable; 아이콘 20×20 at x=16 QPainter로 그림 (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. 생산 시 주의사항

  1. 절대 경로 없음: 모든 에셋 로드는 pathlib.Path(__file__).parent / "assets" / ... 를 사용합니다. 폴백 없이 SVG를 불러오지 말고 QSvgRenderer()를 직접 구성합니다.
  2. int 좌표: 도형을 그릴 때 QPainterPath가 고대비 화면에서 흐려지는 것을 방지하기 위해 0.5 오프셋을 추가하여 int 좌표에 맞춥니다.
  3. 애니메이션 메모리 누수 방지: QPropertyAnimation 인스턴스는 위젯 속성에 저장하거나 QParallelAnimationGroup으로 그룹화해야 합니다. 다른 속성에 의해 참조되지 않은 로컬 변수는 시작 직후 가비지 컬렉션되어 애니메이션이 중단됩니다.
  4. QPropertyAnimation의 제너릭 getter/setter: Q_PROPERTY(float ...)는 pyqtProperty float을 기대합니다. 항상 @pyqtProperty(float, ...) 데코레이터를 사용합니다.
  5. 이벤트 전파: 드래그/드롭 이벤트는 accept()를 호출해야 합니다. 리플은 mousePressEvent에서 super().mousePressEvent(event)를 호출해야 합니다.
  6. QFontDatabase: "Roboto"가 없으면 조용히 시스템 폰트로 대체됩니다. system_font 헬퍼가 이 로직을 캡슐화합니다.
  7. 타이포그래피 스케일: 제목 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)에 따라 작성되었습니다.

관련 스킬