Clock_out_Time_Calculator/ui/dark_components.py
KINDNICK e7e85dcf7b v2.11.1: 통계 차트(frozen) 수정 + 통계/도움말/도전과제 테마 대응
- 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>
2026-06-04 19:08:24 +09:00

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