KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (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>
2026-04-30 12:54:40 +09:00

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