""" 테마 시스템 - 라이트/다크 테마 지원 """ import os import tempfile # ─── 화살표 아이콘 생성 ────────────────────────────────────── _arrow_dir = os.path.join(tempfile.gettempdir(), 'clockout_arrows') os.makedirs(_arrow_dir, exist_ok=True) _light_arrows = {} _dark_arrows = {} _checkmark = '' _icons_initialized = False def _ensure_icons(): """아이콘 PNG가 필요할 때 생성 (QApplication 존재 필요)""" global _light_arrows, _dark_arrows, _checkmark, _icons_initialized if _icons_initialized: return _icons_initialized = True try: from PyQt5.QtGui import QPixmap, QPainter, QColor as _QColor, QPolygon, QPen from PyQt5.QtCore import QPoint except ImportError: return arrows = {} for name, color_hex, points in [ ('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]), ('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]), ('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]), ('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]), ]: path = os.path.join(_arrow_dir, f'{name}.png') if not os.path.exists(path): pm = QPixmap(16, 12) pm.fill(_QColor(0, 0, 0, 0)) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) p.setPen(_QColor(color_hex)) p.setBrush(_QColor(color_hex)) poly = QPolygon([QPoint(x, y) for x, y in points]) p.drawPolygon(poly) p.end() pm.save(path, 'PNG') arrows[name] = path.replace('\\', '/') # 체크마크 아이콘 생성 checkmark_path = os.path.join(_arrow_dir, 'checkmark.png') if not os.path.exists(checkmark_path): pm = QPixmap(14, 14) pm.fill(_QColor(0, 0, 0, 0)) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) pen = QPen(_QColor('#FFFFFF')) pen.setWidth(2) p.setPen(pen) p.drawLine(QPoint(2, 7), QPoint(5, 11)) p.drawLine(QPoint(5, 11), QPoint(11, 3)) p.end() pm.save(checkmark_path, 'PNG') _light_arrows = {k: v for k, v in arrows.items() if 'light' in k} _dark_arrows = {k: v for k, v in arrows.items() if 'dark' in k} _checkmark = checkmark_path.replace('\\', '/') # ─── 색상 정의 ─────────────────────────────────────────────── LIGHT_COLORS = { # 배경 계층 'bg_primary': '#F5F5F7', 'bg_secondary': '#FFFFFF', 'bg_tertiary': '#EDEDF0', # 텍스트 계층 'text_primary': '#1A1A2E', 'text_secondary': '#4A4A68', 'text_tertiary': '#8E8EA0', 'text_inverse': '#FFFFFF', # 액센트 'accent_primary': '#3B82F6', 'accent_success': '#10B981', 'accent_warning': '#F59E0B', 'accent_danger': '#EF4444', # 테두리 'border_subtle': '#E5E7EB', 'border_default': '#D1D5DB', 'border_focus': '#3B82F6', # 배지 배경 'badge_overtime_bg': '#FEF3C7', 'badge_overtime_text': '#92400E', 'badge_leave_bg': '#DBEAFE', 'badge_leave_text': '#1E40AF', 'badge_total_bg': '#D1FAE5', 'badge_total_text': '#065F46', # 프로그레스 'progress_bg': '#E5E7EB', 'progress_start': '#3B82F6', 'progress_end': '#10B981', # 상태 색상 (동적) 'status_overtime': '#EF4444', 'status_warning': '#F59E0B', 'status_normal': '#10B981', 'status_break_active': '#EF4444', 'status_break_idle': '#8E8EA0', # 캘린더 날짜 배경 'cal_normal': '#D1FAE5', 'cal_overtime': '#FEE2E2', 'cal_incomplete': '#FEF9C3', # 스크롤바 'scrollbar_bg': '#F5F5F7', 'scrollbar_handle': '#C4C4CC', 'scrollbar_hover': '#A0A0B0', } DARK_COLORS = { 'bg_primary': '#111118', 'bg_secondary': '#1C1C2E', 'bg_tertiary': '#282842', 'text_primary': '#ECECF4', 'text_secondary': '#B0B0C8', 'text_tertiary': '#808098', 'text_inverse': '#FFFFFF', 'accent_primary': '#6B9EFF', 'accent_success': '#4ADE80', 'accent_warning': '#FCD34D', 'accent_danger': '#FB7185', 'border_subtle': '#32324E', 'border_default': '#44446A', 'border_focus': '#6B9EFF', 'badge_overtime_bg': '#3D2008', 'badge_overtime_text': '#FDE68A', 'badge_leave_bg': '#1E2D5F', 'badge_leave_text': '#A5D0FE', 'badge_total_bg': '#0A3324', 'badge_total_text': '#86EFAC', 'progress_bg': '#282842', 'progress_start': '#6B9EFF', 'progress_end': '#4ADE80', 'status_overtime': '#FB7185', 'status_warning': '#FCD34D', 'status_normal': '#4ADE80', 'status_break_active': '#FB7185', 'status_break_idle': '#808098', 'cal_normal': '#1A4D3A', 'cal_overtime': '#5C1A1A', 'cal_incomplete': '#5C3A10', 'scrollbar_bg': '#111118', 'scrollbar_handle': '#44446A', 'scrollbar_hover': '#5A5A88', } # ─── 현재 테마 상태 ───────────────────────────────────────── class ThemeColors: """런타임에 현재 테마 색상을 참조하는 헬퍼""" current = LIGHT_COLORS @classmethod def set_theme(cls, theme_name: str): cls.current = DARK_COLORS if theme_name == 'dark' else LIGHT_COLORS @classmethod def get(cls, key: str) -> str: return cls.current.get(key, '#FF00FF') # 누락 시 눈에 띄는 색 # ─── QSS 생성 ──────────────────────────────────────────────── def generate_theme(colors: dict, is_dark: bool = False) -> str: """색상 딕셔너리로부터 전체 QSS 문자열 생성""" _ensure_icons() c = colors arrows = _dark_arrows if is_dark else _light_arrows up_arrow = arrows.get('up_dark' if is_dark else 'up_light', '') down_arrow = arrows.get('down_dark' if is_dark else 'down_light', '') checkmark = _checkmark return f""" /* ════════════════════════════════════════ 기본 위젯 ════════════════════════════════════════ */ QMainWindow, QDialog {{ background: {c['bg_primary']}; }} QWidget {{ font-family: "Segoe UI", "맑은 고딕", sans-serif; font-size: 9.5pt; color: {c['text_primary']}; }} QWidget#central_widget {{ background: transparent; }} /* ════════════════════════════════════════ 타이포그래피 ════════════════════════════════════════ */ QLabel#app_title {{ font-size: 12pt; font-weight: bold; color: {c['text_primary']}; padding: 2px; }} QLabel#date_label {{ font-size: 9pt; color: {c['text_secondary']}; padding-bottom: 4px; }} QLabel#section_title {{ font-size: 9.5pt; font-weight: bold; color: {c['text_primary']}; }} QLabel#field_label {{ font-size: 9pt; color: {c['text_secondary']}; }} QLabel#time_value {{ font-family: "Consolas", "D2Coding", monospace; font-size: 11pt; font-weight: bold; color: {c['text_primary']}; }} QLabel#time_display {{ font-family: "Consolas", "D2Coding", monospace; font-size: 22pt; font-weight: bold; color: {c['text_primary']}; background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; border-radius: 10px; padding: 10px; }} QLabel#expected_time {{ font-size: 10pt; font-weight: bold; color: {c['text_primary']}; padding: 4px; }} QLabel#dialog_title {{ font-size: 14pt; font-weight: bold; color: {c['text_primary']}; padding: 16px; }} QLabel#dialog_subtitle {{ font-size: 12pt; font-weight: bold; color: {c['text_primary']}; }} QLabel#stat_value {{ font-size: 10pt; font-weight: bold; color: {c['accent_primary']}; }} QLabel#note_text {{ font-size: 8.5pt; color: {c['text_tertiary']}; }} QLabel#info_text {{ font-size: 9pt; color: {c['accent_danger']}; }} /* ════════════════════════════════════════ 배지 라벨 (배경색 있는 상태 표시) ════════════════════════════════════════ */ QLabel#badge_overtime {{ font-size: 9.5pt; font-weight: bold; padding: 4px 10px; min-width: 110px; qproperty-alignment: AlignCenter; background: {c['badge_overtime_bg']}; color: {c['badge_overtime_text']}; border-radius: 6px; }} QLabel#badge_leave {{ font-size: 9.5pt; font-weight: bold; padding: 4px 10px; min-width: 110px; qproperty-alignment: AlignCenter; background: {c['badge_leave_bg']}; color: {c['badge_leave_text']}; border-radius: 6px; }} QLabel#badge_total {{ font-size: 9.5pt; font-weight: bold; padding: 4px 10px; min-width: 110px; qproperty-alignment: AlignCenter; background: {c['badge_total_bg']}; color: {c['badge_total_text']}; border-radius: 6px; }} QLabel#badge_balance {{ font-size: 12pt; font-weight: bold; padding: 10px; background: {c['bg_tertiary']}; color: {c['text_primary']}; border-radius: 6px; }} QLabel#badge_success {{ font-size: 10pt; font-weight: bold; padding: 8px; background: {c['badge_total_bg']}; color: {c['badge_total_text']}; border-radius: 6px; }} /* ════════════════════════════════════════ 구분선 ════════════════════════════════════════ */ QLabel#separator {{ background: {c['border_subtle']}; max-height: 1px; min-height: 1px; }} /* ════════════════════════════════════════ 그룹 박스 (카드) ════════════════════════════════════════ */ QGroupBox {{ background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; border-radius: 10px; margin-top: 10px; padding: 14px; padding-top: 28px; font-size: 9.5pt; color: {c['text_primary']}; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; padding: 3px 10px; margin-left: 8px; font-weight: bold; color: {c['text_secondary']}; background: {c['bg_secondary']}; border-radius: 4px; }} /* ════════════════════════════════════════ 버튼 ════════════════════════════════════════ */ QPushButton {{ background: {c['bg_tertiary']}; color: {c['text_primary']}; border: 1px solid {c['border_default']}; border-radius: 6px; padding: 7px 14px; font-size: 9pt; }} QPushButton:hover {{ background: {c['border_default']}; }} QPushButton:pressed {{ background: {c['border_subtle']}; }} QPushButton:disabled {{ background: {c['bg_tertiary']}; color: {c['text_tertiary']}; border-color: {c['border_subtle']}; }} QPushButton:checked {{ background: {c['accent_primary']}; color: {c['text_inverse']}; border-color: {c['accent_primary']}; }} /* 퇴근 버튼 (primary action) */ QPushButton#clock_out_button {{ background: {c['accent_success']}; color: {c['text_inverse']}; font-size: 11pt; font-weight: bold; padding: 8px; border: none; border-radius: 8px; }} QPushButton#clock_out_button:hover {{ background: {'#0EA572' if not is_dark else '#2BB885'}; }} QPushButton#clock_out_button:pressed {{ background: {'#0C8F63' if not is_dark else '#28A87A'}; }} /* 주요 액션 버튼 */ QPushButton#btn_primary {{ background: {c['accent_primary']}; color: {c['text_inverse']}; border: none; font-weight: bold; }} QPushButton#btn_primary:hover {{ background: {c['accent_primary']}DD; }} QPushButton#btn_primary:pressed {{ background: {c['accent_primary']}BB; }} /* 위험 버튼 */ QPushButton#btn_danger {{ background: {c['accent_danger']}; color: {c['text_inverse']}; border: none; }} QPushButton#btn_danger:hover {{ background: {c['accent_danger']}DD; }} QPushButton#btn_danger:pressed {{ background: {c['accent_danger']}BB; }} /* 성공 버튼 */ QPushButton#btn_success {{ background: {c['accent_success']}; color: {c['text_inverse']}; border: none; }} QPushButton#btn_success:hover {{ background: {c['accent_success']}DD; }} QPushButton#btn_success:pressed {{ background: {c['accent_success']}BB; }} /* 작은 버튼 */ QPushButton#btn_small {{ font-size: 8.5pt; padding: 5px 10px; }} QPushButton#btn_small:hover {{ background: {c['accent_primary']}20; }} QPushButton#btn_small:pressed {{ background: {c['accent_primary']}35; }} /* ════════════════════════════════════════ 입력 필드 ════════════════════════════════════════ */ QLineEdit, QTextEdit, QComboBox {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; border-radius: 6px; padding: 6px 8px; color: {c['text_primary']}; font-size: 9.5pt; min-height: 20px; }} QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; border-radius: 6px; padding: 6px 28px 6px 8px; color: {c['text_primary']}; font-size: 9.5pt; min-height: 20px; }} QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{ border: 2px solid {c['border_focus']}; padding: 5px 7px; }} QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{ border: 2px solid {c['border_focus']}; padding: 5px 27px 5px 7px; }} /* 비활성 입력 필드 */ QLineEdit:disabled, QTextEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled, QDateEdit:disabled, QTimeEdit:disabled {{ background: {c['bg_tertiary']}; color: {c['text_tertiary']}; border-color: {c['border_subtle']}; }} QComboBox::drop-down {{ subcontrol-origin: padding; subcontrol-position: center right; width: 20px; border: none; padding-right: 4px; }} QComboBox::down-arrow {{ image: url({down_arrow}); width: 10px; height: 8px; }} QComboBox QAbstractItemView {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; color: {c['text_primary']}; selection-background-color: {c['accent_primary']}; selection-color: {c['text_inverse']}; }} QSpinBox::up-button, QSpinBox::down-button, QDoubleSpinBox::up-button, QDoubleSpinBox::down-button, QDateEdit::up-button, QDateEdit::down-button, QTimeEdit::up-button, QTimeEdit::down-button {{ background: {c['bg_tertiary']}; border: 1px solid {c['border_subtle']}; width: 22px; subcontrol-origin: border; }} QSpinBox::up-button, QDoubleSpinBox::up-button, QDateEdit::up-button, QTimeEdit::up-button {{ subcontrol-position: top right; border-top-right-radius: 4px; }} QSpinBox::down-button, QDoubleSpinBox::down-button, QDateEdit::down-button, QTimeEdit::down-button {{ subcontrol-position: bottom right; border-bottom-right-radius: 4px; }} QSpinBox::up-button:hover, QSpinBox::down-button:hover, QDoubleSpinBox::up-button:hover, QDoubleSpinBox::down-button:hover, QDateEdit::up-button:hover, QDateEdit::down-button:hover, QTimeEdit::up-button:hover, QTimeEdit::down-button:hover {{ background: {c['border_default']}; }} QSpinBox::up-arrow, QDoubleSpinBox::up-arrow, QDateEdit::up-arrow, QTimeEdit::up-arrow {{ image: url({up_arrow}); width: 10px; height: 8px; }} QSpinBox::down-arrow, QDoubleSpinBox::down-arrow, QDateEdit::down-arrow, QTimeEdit::down-arrow {{ image: url({down_arrow}); width: 10px; height: 8px; }} /* ════════════════════════════════════════ 체크박스 ════════════════════════════════════════ */ QCheckBox {{ spacing: 8px; color: {c['text_primary']}; font-size: 9pt; }} QCheckBox::indicator {{ width: 18px; height: 18px; border: 2px solid {c['border_default']}; border-radius: 4px; background: {c['bg_secondary']}; }} QCheckBox::indicator:checked {{ background: {c['accent_primary']}; border-color: {c['accent_primary']}; image: url({checkmark}); }} QCheckBox::indicator:hover {{ border-color: {c['accent_primary']}; }} /* ════════════════════════════════════════ 프로그레스 바 ════════════════════════════════════════ */ QProgressBar {{ border: none; background: {c['progress_bg']}; border-radius: 4px; height: 8px; text-align: center; color: transparent; font-size: 0px; }} QProgressBar::chunk {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {c['progress_start']}, stop:1 {c['progress_end']}); border-radius: 4px; }} /* ════════════════════════════════════════ 테이블 ════════════════════════════════════════ */ QTableWidget {{ background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; border-radius: 6px; gridline-color: {c['border_subtle']}; color: {c['text_primary']}; font-size: 9pt; }} QTableWidget::item {{ padding: 6px 8px; }} QTableWidget::item:selected {{ background: {c['accent_primary']}30; color: {c['text_primary']}; }} QTableWidget::item:alternate {{ background: {c['bg_tertiary']}; }} QHeaderView::section {{ background: {c['bg_tertiary']}; color: {c['text_secondary']}; padding: 8px; border: none; border-bottom: 2px solid {c['accent_primary']}; font-weight: bold; font-size: 9pt; }} /* ════════════════════════════════════════ 탭 위젯 ════════════════════════════════════════ */ QTabWidget::pane {{ border: 1px solid {c['border_subtle']}; border-radius: 6px; background: {c['bg_secondary']}; top: -1px; }} QTabBar::tab {{ background: {c['bg_tertiary']}; color: {c['text_secondary']}; padding: 8px 20px; border: 1px solid {c['border_subtle']}; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; margin-right: 2px; font-size: 10pt; }} QTabBar::tab:selected {{ background: {c['bg_secondary']}; color: {c['accent_primary']}; font-weight: bold; border-bottom: 2px solid {c['accent_primary']}; }} QTabBar::tab:hover:!selected {{ background: {c['border_subtle']}; color: {c['text_primary']}; }} /* ════════════════════════════════════════ 스크롤바 ════════════════════════════════════════ */ QScrollBar:vertical {{ background: {c['scrollbar_bg']}; width: 10px; border: none; border-radius: 5px; }} QScrollBar::handle:vertical {{ background: {c['scrollbar_handle']}; min-height: 30px; border-radius: 5px; }} QScrollBar::handle:vertical:hover {{ background: {c['scrollbar_hover']}; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; }} QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: none; }} QScrollBar:horizontal {{ background: {c['scrollbar_bg']}; height: 10px; border: none; border-radius: 5px; }} QScrollBar::handle:horizontal {{ background: {c['scrollbar_handle']}; min-width: 30px; border-radius: 5px; }} QScrollBar::handle:horizontal:hover {{ background: {c['scrollbar_hover']}; }} QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0px; }} QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; }} QScrollArea {{ border: none; background: transparent; }} QWidget#fixed_bottom {{ background: {c['bg_primary']}; border-top: 1px solid {c['border_subtle']}; }} QScrollArea > QWidget > QWidget#scroll_content {{ background: transparent; }} /* ════════════════════════════════════════ 캘린더 ════════════════════════════════════════ */ QCalendarWidget {{ background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; border-radius: 6px; font-size: 10pt; }} QCalendarWidget QAbstractItemView {{ selection-background-color: {c['accent_primary']}; selection-color: {c['text_inverse']}; background: {c['bg_secondary']}; color: {c['text_primary']}; alternate-background-color: {c['bg_secondary']}; }} /* 요일 헤더 행 */ QCalendarWidget QAbstractItemView:enabled {{ color: {c['text_primary']}; }} QCalendarWidget QHeaderView::section {{ background: {c['bg_tertiary']}; color: {c['text_secondary']}; font-weight: bold; border: none; border-bottom: 1px solid {c['border_subtle']}; padding: 4px; }} QCalendarWidget QWidget#qt_calendar_navigationbar {{ background: {c['bg_tertiary']}; border-top-left-radius: 6px; border-top-right-radius: 6px; padding: 4px; }} QCalendarWidget QToolButton {{ color: {c['text_primary']}; background: transparent; font-size: 11pt; font-weight: bold; padding: 6px 12px; border-radius: 6px; min-width: 30px; min-height: 24px; }} QCalendarWidget QToolButton:hover {{ background: {c['accent_primary']}25; color: {c['accent_primary']}; }} QCalendarWidget QToolButton#qt_calendar_prevmonth, QCalendarWidget QToolButton#qt_calendar_nextmonth {{ qproperty-icon: none; min-width: 36px; min-height: 28px; font-size: 14pt; font-weight: bold; color: {c['accent_primary']}; background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; border-radius: 6px; padding: 2px 8px; }} QCalendarWidget QToolButton#qt_calendar_prevmonth {{ qproperty-text: "<"; }} QCalendarWidget QToolButton#qt_calendar_nextmonth {{ qproperty-text: ">"; }} QCalendarWidget QToolButton#qt_calendar_prevmonth:hover, QCalendarWidget QToolButton#qt_calendar_nextmonth:hover {{ background: {c['accent_primary']}; color: {c['text_inverse']}; border-color: {c['accent_primary']}; }} /* ════════════════════════════════════════ 메시지 박스 ════════════════════════════════════════ */ QMessageBox, QInputDialog {{ background: {c['bg_primary']}; }} QMessageBox QLabel, QInputDialog QLabel {{ color: {c['text_primary']}; font-size: 9.5pt; }} QDialogButtonBox QPushButton {{ min-width: 70px; }} /* ════════════════════════════════════════ 툴팁 ════════════════════════════════════════ */ QToolTip {{ background: {c['bg_secondary']}; color: {c['text_primary']}; border: 1px solid {c['border_default']}; border-radius: 4px; padding: 4px 8px; font-size: 9pt; }} /* ════════════════════════════════════════ 메뉴 ════════════════════════════════════════ */ QMenu {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; border-radius: 6px; padding: 4px; color: {c['text_primary']}; }} QMenu::item {{ padding: 6px 24px; border-radius: 4px; }} QMenu::item:selected {{ background: {c['accent_primary']}; color: {c['text_inverse']}; }} """ # ─── 편의 함수 ─────────────────────────────────────────────── def get_light_theme() -> str: return generate_theme(LIGHT_COLORS, is_dark=False) def get_dark_theme() -> str: return generate_theme(DARK_COLORS, is_dark=True) def get_theme(theme_name: str = 'light') -> str: ThemeColors.set_theme(theme_name) if theme_name == 'dark': return get_dark_theme() return get_light_theme() def apply_dark_titlebar(widget, dark: bool = None): """Windows 타이틀바 다크모드 적용 (다이얼로그용)""" if dark is None: dark = ThemeColors.current is DARK_COLORS try: import ctypes hwnd = int(widget.winId()) DWMWA_USE_IMMERSIVE_DARK_MODE = 20 value = ctypes.c_int(1 if dark else 0) ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value) ) except Exception: pass