- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅) - fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종 - dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""
|
|
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
|
|
|
핵심 원칙:
|
|
- 다이얼로그 배경: #0e0e14 (깊은 다크)
|
|
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
|
|
- 헤더: 큰 숫자 + 그라디언트 progress bar
|
|
- 탭: 골드 강조 선택
|
|
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
|
|
|
|
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
|
|
"""
|
|
from __future__ import annotations
|
|
from typing import Optional, List, Tuple
|
|
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
|
|
QProgressBar, QPushButton, QWidget, QSizePolicy,
|
|
QTabWidget)
|
|
from PyQt5.QtCore import Qt
|
|
|
|
|
|
# ── 색상 팔레트 ────────────────────────────────────────────────
|
|
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
|
DARK_BG = '#1A1B1E'
|
|
DARK_PANEL = '#25262B'
|
|
DARK_PANEL_2 = '#2C2E33'
|
|
DARK_BORDER = '#2C2E33'
|
|
DARK_BORDER_STRONG = '#373A40'
|
|
DARK_TEXT = '#E9ECEF'
|
|
DARK_TEXT_DIM = '#909296'
|
|
DARK_TEXT_FAINT = '#6C6E73'
|
|
|
|
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
|
ACCENT_GOLD = '#ffd24a'
|
|
ACCENT_BLUE = '#4DABF7'
|
|
ACCENT_CYAN = '#4adef0'
|
|
ACCENT_PINK = '#ff90b8'
|
|
ACCENT_GREEN = '#51CF66'
|
|
ACCENT_ORANGE = '#fcd34d'
|
|
ACCENT_RED = '#FA5252'
|
|
|
|
# 카드 테마 (등급/상태별)
|
|
CARD_THEMES = {
|
|
'gold': {
|
|
'border': '#ffb700', 'border_strong': '#ffd24a',
|
|
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
|
|
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
|
|
},
|
|
'blue': {
|
|
'border': '#5a8eff', 'border_strong': '#6b9eff',
|
|
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
|
|
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
|
|
},
|
|
'cyan': {
|
|
'border': '#3acce0', 'border_strong': '#4adef0',
|
|
'bg_top': '#0e3340', 'bg_bot': '#08222b',
|
|
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
|
|
},
|
|
'green': {
|
|
'border': '#3ace70', 'border_strong': '#4ade80',
|
|
'bg_top': '#0e3324', 'bg_bot': '#082218',
|
|
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
|
|
},
|
|
'pink': {
|
|
'border': '#ff5a8c', 'border_strong': '#ff90b8',
|
|
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
|
|
'text': '#ffc0d4', 'accent': ACCENT_PINK,
|
|
},
|
|
'red': {
|
|
'border': '#ea5566', 'border_strong': '#fb7185',
|
|
'bg_top': '#3a1620', 'bg_bot': '#260e16',
|
|
'text': '#ffb8c0', 'accent': ACCENT_RED,
|
|
},
|
|
'gray': {
|
|
'border': '#44446a', 'border_strong': '#666688',
|
|
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
|
|
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
|
|
},
|
|
}
|
|
|
|
|
|
# ── 테마 연동 ──────────────────────────────────────────────────
|
|
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
|
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
|
|
|
|
def _pal() -> dict:
|
|
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
|
|
from ui.styles import ThemeColors
|
|
g = ThemeColors.get
|
|
return {
|
|
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
|
|
'border': g('border_subtle'), 'border_strong': g('border_default'),
|
|
'text': g('text_primary'), 'text_dim': g('text_secondary'),
|
|
'text_faint': g('text_tertiary'),
|
|
'blue': g('accent_primary'), 'green': g('accent_success'),
|
|
'red': g('accent_danger'),
|
|
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
|
|
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
|
|
}
|
|
|
|
|
|
def _is_dark() -> bool:
|
|
from ui.styles import ThemeColors, DARK_COLORS
|
|
return ThemeColors.current is DARK_COLORS
|
|
|
|
|
|
def tc(role: str) -> str:
|
|
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
|
|
return _pal().get(role, '#FF00FF')
|
|
|
|
|
|
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
|
|
|
def dialog_qss() -> str:
|
|
"""다이얼로그 전체 배경 (현재 테마)."""
|
|
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
|
|
|
|
|
def tabs_qss(accent: str = None) -> str:
|
|
p = _pal()
|
|
if accent is None:
|
|
accent = p['blue']
|
|
return f"""
|
|
QTabWidget::pane {{
|
|
background: {p['panel']};
|
|
border: 1px solid {p['border']};
|
|
border-radius: 10px;
|
|
top: -1px;
|
|
}}
|
|
QTabBar::tab {{
|
|
background: {p['panel2']};
|
|
color: {p['text_dim']};
|
|
padding: 9px 18px;
|
|
border: 1px solid {p['border']};
|
|
border-bottom: none;
|
|
border-top-left-radius: 8px;
|
|
border-top-right-radius: 8px;
|
|
margin-right: 3px;
|
|
font-size: 10pt;
|
|
}}
|
|
QTabBar::tab:selected {{
|
|
background: {p['panel']};
|
|
color: {accent};
|
|
font-weight: bold;
|
|
border-bottom: 2px solid {accent};
|
|
}}
|
|
QTabBar::tab:hover:!selected {{
|
|
background: {p['border_strong']};
|
|
color: {p['text']};
|
|
}}
|
|
"""
|
|
|
|
|
|
def scroll_qss() -> str:
|
|
p = _pal()
|
|
return f"""
|
|
QScrollArea {{ background: transparent; border: none; }}
|
|
QScrollBar:vertical {{
|
|
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
|
}}
|
|
QScrollBar::handle:vertical {{
|
|
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
|
}}
|
|
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
|
QScrollBar:horizontal {{
|
|
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
|
}}
|
|
QScrollBar::handle:horizontal {{
|
|
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
|
}}
|
|
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
|
"""
|
|
|
|
|
|
def button_qss(variant: str = 'default') -> str:
|
|
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
|
p = _pal()
|
|
if variant == 'primary':
|
|
return f"""
|
|
QPushButton {{
|
|
background: {p['blue']}; color: white;
|
|
border: none; border-radius: 8px;
|
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: {p['blue_hover']}; }}
|
|
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
|
|
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
|
|
"""
|
|
if variant == 'success':
|
|
return f"""
|
|
QPushButton {{
|
|
background: {p['green']}; color: white;
|
|
border: none; border-radius: 8px;
|
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: {p['green_hover']}; }}
|
|
"""
|
|
if variant == 'danger':
|
|
return f"""
|
|
QPushButton {{
|
|
background: {p['red']}; color: white;
|
|
border: none; border-radius: 8px;
|
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: {p['red_hover']}; }}
|
|
"""
|
|
if variant == 'ghost':
|
|
return f"""
|
|
QPushButton {{
|
|
background: transparent; color: {p['text_dim']};
|
|
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
|
padding: 6px 14px; font-size: 9.5pt;
|
|
}}
|
|
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
|
border-color: {p['blue']}; }}
|
|
"""
|
|
# default
|
|
return f"""
|
|
QPushButton {{
|
|
background: {p['panel2']}; color: {p['text']};
|
|
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
|
padding: 8px 18px; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
|
|
"""
|
|
|
|
|
|
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
|
|
|
|
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
|
big_color: str = ACCENT_GOLD,
|
|
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
|
|
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
|
|
|
|
Args:
|
|
title: 큰 숫자 위 작은 라벨 (예: "달성")
|
|
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
|
|
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
|
|
big_color: 큰 숫자 색
|
|
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
|
"""
|
|
p = _pal()
|
|
container = QFrame()
|
|
container.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: {p['panel']};
|
|
border: 1px solid {p['border']};
|
|
border-radius: 8px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
|
|
""")
|
|
layout = QHBoxLayout()
|
|
layout.setContentsMargins(20, 14, 20, 14)
|
|
layout.setSpacing(20)
|
|
|
|
# 좌측: 제목 + 큰 숫자
|
|
left = QVBoxLayout()
|
|
left.setSpacing(2)
|
|
if title:
|
|
t = QLabel(title)
|
|
t.setStyleSheet(
|
|
f"font-size: 9pt; color: {p['text_dim']}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
left.addWidget(t)
|
|
big = QLabel(
|
|
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
|
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
|
|
f" {subtitle}</span>" if subtitle else '')
|
|
)
|
|
big.setTextFormat(Qt.RichText)
|
|
big.setStyleSheet("background: transparent; border: none;")
|
|
left.addWidget(big)
|
|
layout.addLayout(left)
|
|
|
|
# 우측: extra widgets
|
|
if extra_widgets:
|
|
layout.addStretch()
|
|
for w in extra_widgets:
|
|
layout.addWidget(w)
|
|
else:
|
|
layout.addStretch()
|
|
|
|
container.setLayout(layout)
|
|
return container
|
|
|
|
|
|
def build_stat_card(title: str, value: str, subtitle: str = '',
|
|
theme: str = 'blue', icon: str = '') -> QFrame:
|
|
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
|
p = _pal()
|
|
dark = _is_dark()
|
|
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
|
|
if dark:
|
|
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
|
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
|
card_border = t['border']
|
|
label_color = t['text']
|
|
value_color = t['border_strong']
|
|
else:
|
|
card_bg = p['panel']
|
|
card_border = p['border']
|
|
label_color = p['text']
|
|
value_color = p['text']
|
|
card = QFrame()
|
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: {card_bg};
|
|
border: 1px solid {card_border};
|
|
border-radius: 10px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
|
""")
|
|
outer = QHBoxLayout()
|
|
outer.setContentsMargins(16, 12, 16, 12)
|
|
outer.setSpacing(12)
|
|
|
|
if icon:
|
|
icon_lbl = QLabel()
|
|
icon_lbl.setMinimumWidth(48)
|
|
icon_lbl.setAlignment(Qt.AlignCenter)
|
|
from ui.icons import get_icon, _PATHS
|
|
if icon in _PATHS:
|
|
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
|
|
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
|
|
else:
|
|
# 이모지/텍스트 폴백 (구버전 호환)
|
|
icon_lbl.setText(icon)
|
|
icon_lbl.setStyleSheet(
|
|
f"font-size: 28pt; background: transparent; border: none; "
|
|
f"color: {t['border_strong']};"
|
|
)
|
|
outer.addWidget(icon_lbl)
|
|
|
|
text_box = QVBoxLayout()
|
|
text_box.setSpacing(2)
|
|
|
|
title_lbl = QLabel(title)
|
|
title_lbl.setStyleSheet(
|
|
f"font-size: 9.5pt; color: {p['text_dim']}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
text_box.addWidget(title_lbl)
|
|
|
|
val_lbl = QLabel(
|
|
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
|
|
f"{value}</span>"
|
|
)
|
|
val_lbl.setTextFormat(Qt.RichText)
|
|
val_lbl.setStyleSheet("background: transparent; border: none;")
|
|
text_box.addWidget(val_lbl)
|
|
|
|
if subtitle:
|
|
sub_lbl = QLabel(subtitle)
|
|
sub_lbl.setStyleSheet(
|
|
f"font-size: 9pt; color: {p['text_dim']}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
sub_lbl.setWordWrap(True)
|
|
text_box.addWidget(sub_lbl)
|
|
|
|
outer.addLayout(text_box, 1)
|
|
card.setLayout(outer)
|
|
return card
|
|
|
|
|
|
def build_section_card(title: str, content: QWidget,
|
|
theme: str = 'gray', icon: str = '') -> QFrame:
|
|
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
|
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
|
p = _pal()
|
|
if _is_dark():
|
|
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
|
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
|
card_border = t['border']
|
|
label_color = t['text']
|
|
else:
|
|
card_bg = p['panel']
|
|
card_border = p['border']
|
|
label_color = p['text']
|
|
card = QFrame()
|
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: {card_bg};
|
|
border: 1px solid {card_border};
|
|
border-radius: 10px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
|
""")
|
|
layout = QVBoxLayout()
|
|
layout.setContentsMargins(16, 12, 16, 14)
|
|
layout.setSpacing(8)
|
|
|
|
head = QHBoxLayout()
|
|
if icon:
|
|
i = QLabel()
|
|
from ui.icons import get_icon, _PATHS
|
|
if icon in _PATHS:
|
|
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
|
|
else:
|
|
i.setText(icon)
|
|
i.setStyleSheet(
|
|
f"font-size: 16pt; color: {t['border_strong']}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
head.addWidget(i)
|
|
title_lbl = QLabel(title)
|
|
title_lbl.setStyleSheet(
|
|
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
head.addWidget(title_lbl)
|
|
head.addStretch()
|
|
layout.addLayout(head)
|
|
|
|
layout.addWidget(content, 1)
|
|
card.setLayout(layout)
|
|
return card
|
|
|
|
|
|
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
|
height: int = 10) -> None:
|
|
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
|
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
|
pb.setMinimumHeight(height)
|
|
pb.setMaximumHeight(height)
|
|
pb.setTextVisible(False)
|
|
pb.setStyleSheet(f"""
|
|
QProgressBar {{
|
|
background: rgba(0,0,0,0.4);
|
|
border: none;
|
|
border-radius: {height // 2}px;
|
|
}}
|
|
QProgressBar::chunk {{
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 {t['border']}, stop:1 {t['border_strong']});
|
|
border-radius: {height // 2}px;
|
|
}}
|
|
""")
|
|
|
|
|
|
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
|
color: str = None) -> QLabel:
|
|
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
|
lbl = QLabel(text)
|
|
if color is None:
|
|
color = _pal()['text']
|
|
weight_str = 'bold' if weight == 'bold' else 'normal'
|
|
lbl.setStyleSheet(
|
|
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
|
f"background: transparent; border: none; padding: 0; margin: 0;"
|
|
)
|
|
return lbl
|