Some checks failed
CI / test (push) Has been cancelled
핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의) - Windows 이벤트 뷰어 자동 출퇴근 감지 - 30분 단위 연장근무 적립/사용 시스템 - 1.0/0.5/0.25일 연차·반차·반반차 - 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출 - 한국 공휴일 자동 등록 (음력 포함, holidays 패키지) - matplotlib 차트 기반 주간/월간/패턴 통계 - 미니 위젯 + 시스템 트레이 통합 - 한국어/English i18n - 자가 업데이트 (updater.exe + Gitea Releases) 아키텍처: - core/ (db, time_calculator, notifier, i18n, version, settings_keys) - ui/ (main_window + 9 dialogs + 3 controllers) - utils/ (backup, lock_detector, debug_log, updater_client, time_format) - tests/ (66 pytest 단위) + 통합/i18n GUI 검증 CI/CD: - .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트 - .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
956 lines
26 KiB
Python
956 lines
26 KiB
Python
"""
|
|
테마 시스템 - 라이트/다크 테마 지원
|
|
"""
|
|
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
|
|
|
|
|