diff --git a/CHANGELOG.md b/CHANGELOG.md
index e37d99b..092e4f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [2.11.0] — 2026-06-04
+
+### Changed — UI 전면 다크 리디자인
+- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
+ 단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
+- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
+- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
+ `main.spec`에 동봉
+- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
+ 입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
+- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
+
+### Added
+- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
+ 하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함)
+- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
+ (`Database.delete_overtime_earned`)
+
+### Fixed
+- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
+ 이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
+ (`clock_out` 대화상자 '아니오' 경로는 정상이었음)
+- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
+ 트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
+- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
+
+### Tests
+- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
+
## [2.10.2] — 2026-05-16
### Fixed
diff --git a/core/database.py b/core/database.py
index 52ae024..d08e5ae 100644
--- a/core/database.py
+++ b/core/database.py
@@ -770,7 +770,7 @@ class Database:
'dinner_duration_minutes': '60',
'auto_lunch': 'false',
'auto_overtime': 'true',
- 'theme': 'light',
+ 'theme': 'dark',
'notification_before_minutes': '30',
'notification_clock_out': 'true',
'notification_lunch': 'true',
@@ -977,6 +977,19 @@ class Database:
''', (work_record_id, earned_minutes, date))
conn.commit()
+ def delete_overtime_earned(self, bank_id: int) -> bool:
+ """연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소.
+
+ Returns:
+ bool: 실제로 삭제된 행이 있으면 True.
+ """
+ with self._conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,))
+ deleted = cursor.rowcount
+ conn.commit()
+ return deleted > 0
+
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
date: str, reason: str = None):
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
diff --git a/core/i18n.py b/core/i18n.py
index 3b1f168..96d9d3a 100644
--- a/core/i18n.py
+++ b/core/i18n.py
@@ -21,14 +21,14 @@ _DICT = {
'menu.help': '도움말',
'menu.settings': '설정',
'btn.clock_out': '퇴근하기',
- 'btn.clock_out_cancel': '🔄 퇴근 취소',
+ 'btn.clock_out_cancel': '퇴근 취소',
'btn.lunch_add': '점심시간 추가',
'btn.lunch_applied': '점심시간 (적용됨)',
'btn.dinner_add': '저녁시간 추가',
'btn.dinner_applied': '저녁시간 (적용됨)',
- 'btn.break_out': '🚪 외출 시작',
- 'btn.break_in': '↩️ 복귀',
- 'btn.save': '💾 저장',
+ 'btn.break_out': '외출 시작',
+ 'btn.break_in': '복귀',
+ 'btn.save': '저장',
'btn.close': '닫기',
'btn.apply': '적용',
'btn.cancel': '취소',
@@ -40,10 +40,10 @@ _DICT = {
# === 윈도우/다이얼로그 제목 ===
'window.main_title': '퇴근시간 계산기',
- 'window.settings': '⚙️ 설정',
- 'window.help': '📖 사용 설명서',
- 'window.stats': '📊 근무 통계',
- 'window.calendar': '📅 캘린더',
+ 'window.settings': '설정',
+ 'window.help': '사용 설명서',
+ 'window.stats': '근무 통계',
+ 'window.calendar': '캘린더',
'window.mini_widget': '퇴근시간',
'window.clock_in_dialog': '출근 시간',
'window.break_view': '외출 관리',
@@ -125,8 +125,8 @@ _DICT = {
# === 트레이 ===
'tray.open': '프로그램 열기',
- 'tray.mini_widget': '📌 미니 위젯',
- 'tray.toggle_lunch': '🍱 점심시간 토글',
+ 'tray.mini_widget': '미니 위젯',
+ 'tray.toggle_lunch': '점심시간 토글',
'tray.quit': '종료',
'tray.tooltip_remaining': '퇴근까지: {time}',
'tray.tooltip_overtime': '추가 근무 중: {time}',
@@ -166,12 +166,12 @@ _DICT = {
'cal.edit_record': '기록 편집',
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
- 'help.tab_intro': '👋 시작하기',
- 'help.tab_work_hours': '🕘 근무시간',
- 'help.tab_overtime': '🏦 연장근무',
- 'help.tab_leave': '🌴 연차/휴가',
- 'help.tab_break': '🚪 외출/저녁',
- 'help.tab_faq': '❓ 자주 묻는 질문',
+ 'help.tab_intro': '시작하기',
+ 'help.tab_work_hours': '근무시간',
+ 'help.tab_overtime': '연장근무',
+ 'help.tab_leave': '연차/휴가',
+ 'help.tab_break': '외출/저녁',
+ 'help.tab_faq': '자주 묻는 질문',
# === clock_in_dialog ===
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
@@ -204,17 +204,18 @@ _DICT = {
'view.overtime.title': '연장근무 내역',
'view.overtime.balance_zero': '잔액: 0분',
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
- 'view.overtime.earned_group': '💰 적립 내역',
- 'view.overtime.used_group': '📤 사용 내역',
+ 'view.overtime.earned_group': '적립 내역',
+ 'view.overtime.used_group': '사용 내역',
'view.overtime.col_date': '날짜',
'view.overtime.col_earned': '적립',
'view.overtime.col_used': '사용',
'view.overtime.col_memo': '메모',
'view.overtime.col_reason': '사유',
- 'view.overtime.btn_add_earned': '➕ 수동 적립',
- 'view.overtime.btn_add_used': '➕ 수동 사용',
- 'view.overtime.menu_delete': '❌ 삭제',
+ 'view.overtime.btn_add_earned': '수동 적립',
+ 'view.overtime.btn_add_used': '수동 사용',
+ 'view.overtime.menu_delete': '삭제',
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
+ 'view.overtime.delete_earned_confirm_body': '다음 적립 기록을 삭제하시겠습니까?\n\n날짜: {date}\n적립: {time}\n\n삭제 시 잔액에서 차감됩니다.',
'view.overtime.manual_earned_title': '추가근무 수동 적립',
'view.overtime.manual_used_title': '추가근무 수동 사용',
'view.overtime.field_date': '날짜:',
@@ -238,13 +239,13 @@ _DICT = {
'view.leave.balance_zero': '잔여: 0일',
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
'view.leave.btn_set_balance': '잔여 설정',
- 'view.leave.used_group': '📤 사용 내역',
+ 'view.leave.used_group': '사용 내역',
'view.leave.col_date': '날짜',
'view.leave.col_type': '구분',
'view.leave.col_used': '사용',
'view.leave.col_reason': '사유',
- 'view.leave.btn_add': '➕ 연차 사용 추가',
- 'view.leave.btn_calendar': '📅 캘린더 보기',
+ 'view.leave.btn_add': '연차 사용 추가',
+ 'view.leave.btn_calendar': '캘린더 보기',
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
'view.leave.set_title': '연차 시간 설정',
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
@@ -278,14 +279,14 @@ _DICT = {
'menu.help': 'Help',
'menu.settings': 'Settings',
'btn.clock_out': 'Clock Out',
- 'btn.clock_out_cancel': '🔄 Cancel Clock-out',
+ 'btn.clock_out_cancel': 'Cancel Clock-out',
'btn.lunch_add': 'Add Lunch',
'btn.lunch_applied': 'Lunch (Applied)',
'btn.dinner_add': 'Add Dinner',
'btn.dinner_applied': 'Dinner (Applied)',
- 'btn.break_out': '🚪 Start Break',
- 'btn.break_in': '↩️ Return',
- 'btn.save': '💾 Save',
+ 'btn.break_out': 'Start Break',
+ 'btn.break_in': 'Return',
+ 'btn.save': 'Save',
'btn.close': 'Close',
'btn.apply': 'Apply',
'btn.cancel': 'Cancel',
@@ -297,10 +298,10 @@ _DICT = {
# === Windows ===
'window.main_title': 'Clock-out Time Calculator',
- 'window.settings': '⚙️ Settings',
- 'window.help': '📖 User Guide',
- 'window.stats': '📊 Statistics',
- 'window.calendar': '📅 Calendar',
+ 'window.settings': 'Settings',
+ 'window.help': 'User Guide',
+ 'window.stats': 'Statistics',
+ 'window.calendar': 'Calendar',
'window.mini_widget': 'Clock-out',
'window.clock_in_dialog': 'Clock-in Time',
'window.break_view': 'Break Management',
@@ -382,8 +383,8 @@ _DICT = {
# === Tray ===
'tray.open': 'Open Program',
- 'tray.mini_widget': '📌 Mini Widget',
- 'tray.toggle_lunch': '🍱 Toggle Lunch',
+ 'tray.mini_widget': 'Mini Widget',
+ 'tray.toggle_lunch': 'Toggle Lunch',
'tray.quit': 'Quit',
'tray.tooltip_remaining': 'Until clock-out: {time}',
'tray.tooltip_overtime': 'Overtime: {time}',
@@ -423,12 +424,12 @@ _DICT = {
'cal.edit_record': 'Edit record',
# === HelpView ===
- 'help.tab_intro': '👋 Getting Started',
- 'help.tab_work_hours': '🕘 Work Hours',
- 'help.tab_overtime': '🏦 Overtime',
- 'help.tab_leave': '🌴 Leave',
- 'help.tab_break': '🚪 Break/Dinner',
- 'help.tab_faq': '❓ FAQ',
+ 'help.tab_intro': 'Getting Started',
+ 'help.tab_work_hours': 'Work Hours',
+ 'help.tab_overtime': 'Overtime',
+ 'help.tab_leave': 'Leave',
+ 'help.tab_break': 'Break/Dinner',
+ 'help.tab_faq': 'FAQ',
# === clock_in_dialog ===
'dlg.clock_in.prompt': "Enter today's clock-in time",
@@ -461,17 +462,18 @@ _DICT = {
'view.overtime.title': 'Overtime History',
'view.overtime.balance_zero': 'Balance: 0 min',
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
- 'view.overtime.earned_group': '💰 Earned',
- 'view.overtime.used_group': '📤 Used',
+ 'view.overtime.earned_group': 'Earned',
+ 'view.overtime.used_group': 'Used',
'view.overtime.col_date': 'Date',
'view.overtime.col_earned': 'Earned',
'view.overtime.col_used': 'Used',
'view.overtime.col_memo': 'Memo',
'view.overtime.col_reason': 'Reason',
- 'view.overtime.btn_add_earned': '➕ Manual Earn',
- 'view.overtime.btn_add_used': '➕ Manual Use',
- 'view.overtime.menu_delete': '❌ Delete',
+ 'view.overtime.btn_add_earned': 'Manual Earn',
+ 'view.overtime.btn_add_used': 'Manual Use',
+ 'view.overtime.menu_delete': 'Delete',
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
+ 'view.overtime.delete_earned_confirm_body': 'Delete this accrual record?\n\nDate: {date}\nEarned: {time}\n\nThe balance will be reduced accordingly.',
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
'view.overtime.manual_used_title': 'Manual Overtime Use',
'view.overtime.field_date': 'Date:',
@@ -495,13 +497,13 @@ _DICT = {
'view.leave.balance_zero': 'Balance: 0 days',
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
'view.leave.btn_set_balance': 'Set Balance',
- 'view.leave.used_group': '📤 Used',
+ 'view.leave.used_group': 'Used',
'view.leave.col_date': 'Date',
'view.leave.col_type': 'Type',
'view.leave.col_used': 'Used',
'view.leave.col_reason': 'Reason',
- 'view.leave.btn_add': '➕ Add Leave Usage',
- 'view.leave.btn_calendar': '📅 Calendar',
+ 'view.leave.btn_add': 'Add Leave Usage',
+ 'view.leave.btn_calendar': 'Calendar',
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
'view.leave.set_title': 'Set Leave Hours',
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',
diff --git a/core/version.py b/core/version.py
index 62289d3..7675549 100644
--- a/core/version.py
+++ b/core/version.py
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
-__version__ = '2.10.2'
+__version__ = '2.11.0'
diff --git a/font/NanumSquareB.otf b/font/NanumSquareB.otf
new file mode 100644
index 0000000..944742a
Binary files /dev/null and b/font/NanumSquareB.otf differ
diff --git a/font/NanumSquareB.ttf b/font/NanumSquareB.ttf
new file mode 100644
index 0000000..7711cac
Binary files /dev/null and b/font/NanumSquareB.ttf differ
diff --git a/font/NanumSquareEB.otf b/font/NanumSquareEB.otf
new file mode 100644
index 0000000..8fda576
Binary files /dev/null and b/font/NanumSquareEB.otf differ
diff --git a/font/NanumSquareEB.ttf b/font/NanumSquareEB.ttf
new file mode 100644
index 0000000..cb107b6
Binary files /dev/null and b/font/NanumSquareEB.ttf differ
diff --git a/font/NanumSquareL.otf b/font/NanumSquareL.otf
new file mode 100644
index 0000000..aa0f117
Binary files /dev/null and b/font/NanumSquareL.otf differ
diff --git a/font/NanumSquareL.ttf b/font/NanumSquareL.ttf
new file mode 100644
index 0000000..285681b
Binary files /dev/null and b/font/NanumSquareL.ttf differ
diff --git a/font/NanumSquareOTF_acB.otf b/font/NanumSquareOTF_acB.otf
new file mode 100644
index 0000000..563a700
Binary files /dev/null and b/font/NanumSquareOTF_acB.otf differ
diff --git a/font/NanumSquareOTF_acEB.otf b/font/NanumSquareOTF_acEB.otf
new file mode 100644
index 0000000..04fe861
Binary files /dev/null and b/font/NanumSquareOTF_acEB.otf differ
diff --git a/font/NanumSquareOTF_acL.otf b/font/NanumSquareOTF_acL.otf
new file mode 100644
index 0000000..e8dec12
Binary files /dev/null and b/font/NanumSquareOTF_acL.otf differ
diff --git a/font/NanumSquareOTF_acR.otf b/font/NanumSquareOTF_acR.otf
new file mode 100644
index 0000000..499967d
Binary files /dev/null and b/font/NanumSquareOTF_acR.otf differ
diff --git a/font/NanumSquareR.otf b/font/NanumSquareR.otf
new file mode 100644
index 0000000..305829d
Binary files /dev/null and b/font/NanumSquareR.otf differ
diff --git a/font/NanumSquareR.ttf b/font/NanumSquareR.ttf
new file mode 100644
index 0000000..9564297
Binary files /dev/null and b/font/NanumSquareR.ttf differ
diff --git a/font/NanumSquare_acB.ttf b/font/NanumSquare_acB.ttf
new file mode 100644
index 0000000..1510860
Binary files /dev/null and b/font/NanumSquare_acB.ttf differ
diff --git a/font/NanumSquare_acEB.ttf b/font/NanumSquare_acEB.ttf
new file mode 100644
index 0000000..6a22e06
Binary files /dev/null and b/font/NanumSquare_acEB.ttf differ
diff --git a/font/NanumSquare_acL.ttf b/font/NanumSquare_acL.ttf
new file mode 100644
index 0000000..5929b95
Binary files /dev/null and b/font/NanumSquare_acL.ttf differ
diff --git a/font/NanumSquare_acR.ttf b/font/NanumSquare_acR.ttf
new file mode 100644
index 0000000..3b43e02
Binary files /dev/null and b/font/NanumSquare_acR.ttf differ
diff --git a/main.py b/main.py
index 5441e4a..4d9585e 100644
--- a/main.py
+++ b/main.py
@@ -96,8 +96,9 @@ def main():
)
return 1
- # 폰트 설정
- app.setFont(QFont("Segoe UI", 9))
+ # 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
+ from utils.font_loader import apply_app_font
+ apply_app_font(app, 9)
# 필수 패키지 확인
if not check_requirements():
diff --git a/main.spec b/main.spec
index 056291a..45fadd9 100644
--- a/main.spec
+++ b/main.spec
@@ -14,15 +14,26 @@ if os.path.exists(_staged):
elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.'))
+# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
+_font_files = [
+ 'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
+ 'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
+]
+_font_datas = [
+ (os.path.join('font', f), 'font')
+ for f in _font_files if os.path.exists(os.path.join('font', f))
+]
+
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
- datas=[('3d-alarm.png', '.')] + _extra_datas,
+ datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
hiddenimports=[
'holidays', 'holidays.countries.south_korea',
'win32evtlog', 'win32evtlogutil',
'matplotlib.backends.backend_qt5agg',
+ 'PyQt5.QtSvg',
],
hookspath=[],
hooksconfig={},
diff --git a/tests/test_i18n_runtime.py b/tests/test_i18n_runtime.py
index ccee855..937238a 100644
--- a/tests/test_i18n_runtime.py
+++ b/tests/test_i18n_runtime.py
@@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save')
- assert label.text() == '💾 저장'
+ assert label.text() == '저장'
def test_retranslate_after_language_change(qapp, i18n):
@@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
set_language('ko')
dlg = QDialog()
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
- assert dlg.windowTitle() == '⚙️ 설정'
+ assert dlg.windowTitle() == '설정'
i18n.set_language_and_retranslate('en')
- assert dlg.windowTitle() == '⚙️ Settings'
+ assert dlg.windowTitle() == 'Settings'
def test_post_callback_applied(qapp, i18n):
@@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
- assert label.text() == '[💾 저장]'
+ assert label.text() == '[저장]'
i18n.set_language_and_retranslate('en')
- assert label.text() == '[💾 Save]'
+ assert label.text() == '[Save]'
def test_dead_widget_pruned(qapp, i18n):
diff --git a/tests/test_overtime_accrual_guard.py b/tests/test_overtime_accrual_guard.py
new file mode 100644
index 0000000..0ac57d5
--- /dev/null
+++ b/tests/test_overtime_accrual_guard.py
@@ -0,0 +1,72 @@
+"""연장근무 자동 적립 가드 테스트.
+
+auto_overtime(자동 적립)가 OFF면, 자동 퇴근 경로(근무일 경계 롤오버 등)에서도
+은행 적립을 하지 않아야 한다 — clock_out() 대화상자에서 '아니오'를 고른 것과 동일한 의미.
+
+handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
+__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
+"""
+import os
+import sys
+from datetime import datetime, timedelta, time as dtime
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from core.database import Database
+from core.time_calculator import TimeCalculator
+from ui.main_window import MainWindow
+
+
+def _rollover_balance(db, monkeypatch):
+ """어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
+ from PyQt5.QtWidgets import QMessageBox
+ monkeypatch.setattr(QMessageBox, 'information',
+ staticmethod(lambda *a, **k: QMessageBox.Ok))
+
+ today = datetime.now().date()
+ y = today - timedelta(days=1)
+ db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
+
+ w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
+ w.db = db
+ w.time_calc = TimeCalculator(work_minutes=480)
+ w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
+ w.is_clocked_in = True
+ w.midnight_rollover_handled = False
+ w.is_on_break = False
+ w.lunch_break_enabled = False
+ w.dinner_break_enabled = False
+ w.load_today_data = lambda: None # tail UI refresh stub
+ w.update_overtime_balance = lambda: None # tail UI refresh stub
+
+ w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
+ return db.get_total_overtime_balance()
+
+
+def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
+ db = Database(str(tmp_path / 'off.db'))
+ db.set_setting('auto_overtime', 'false')
+ assert _rollover_balance(db, monkeypatch) == 0
+
+
+def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
+ db = Database(str(tmp_path / 'on.db'))
+ db.set_setting('auto_overtime', 'true')
+ assert _rollover_balance(db, monkeypatch) > 0
+
+
+def test_delete_overtime_earned_reduces_balance(tmp_path):
+ """적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
+ from datetime import date
+ db = Database(str(tmp_path / 'del.db'))
+ today = date.today().isoformat()
+ db.add_overtime_earned(None, 90, today)
+ assert db.get_total_overtime_balance() == 90
+
+ bank_id = db.get_connection().execute(
+ 'SELECT id FROM overtime_bank').fetchone()[0]
+ assert db.delete_overtime_earned(bank_id) is True
+ assert db.get_total_overtime_balance() == 0
+
+ # 없는 id 삭제는 False
+ assert db.delete_overtime_earned(999999) is False
diff --git a/ui/achievements_view.py b/ui/achievements_view.py
index 9662317..6ce9b12 100644
--- a/ui/achievements_view.py
+++ b/ui/achievements_view.py
@@ -85,11 +85,11 @@ class AchievementsView(QDialog):
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
- self.setWindowTitle("🏆 도전과제")
+ self.setWindowTitle("도전과제")
self.setMinimumSize(960, 720)
self.resize(1100, 800)
self._increment_view_count()
- self.setStyleSheet("QDialog { background: #0e0e14; }")
+ self.setStyleSheet("QDialog { background: #1A1B1E; }")
self.init_ui()
apply_dark_titlebar(self, dark=True)
@@ -157,7 +157,7 @@ class AchievementsView(QDialog):
QFrame {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #1a1a30, stop:1 #2a1a3a);
- border: 1px solid #3a3a5a;
+ border: 1px solid #2C2E33;
border-radius: 12px;
}
QLabel { background: transparent; border: none; color: #e8e8f4; }
@@ -173,21 +173,21 @@ class AchievementsView(QDialog):
num_row.setSpacing(24)
big = QLabel(f"{stats['earned']}"
- f" / {stats['total']}")
+ f" / {stats['total']}")
big.setTextFormat(Qt.RichText)
num_row.addWidget(big)
spacer = QFrame()
spacer.setFrameShape(QFrame.VLine)
- spacer.setStyleSheet("color: #3a3a5a;")
+ spacer.setStyleSheet("color: #2C2E33;")
num_row.addWidget(spacer)
secret_lbl = QLabel(
f"
"
- f"🌑 시크릿
"
+ f"🌑 시크릿
"
f""
f"{stats['secret_earned']}"
- f" / {stats['secret_total']}"
+ f" / {stats['secret_total']}"
f"
"
)
secret_lbl.setTextFormat(Qt.RichText)
@@ -197,7 +197,7 @@ class AchievementsView(QDialog):
pct_lbl = QLabel(
f""
- f"달성률
"
+ f"달성률
"
f""
f"{pct:.1f}%
"
)
@@ -256,7 +256,7 @@ class AchievementsView(QDialog):
empty = QLabel("(아직 없음)")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet(
- "color: #666; padding: 60px; font-size: 12pt; background: transparent;"
+ "color: #6C6E73; padding: 60px; font-size: 12pt; background: transparent;"
)
grid.addWidget(empty, 0, 0)
else:
@@ -283,7 +283,7 @@ class AchievementsView(QDialog):
if is_locked_secret:
bg_top, bg_bot = '#1a1a26', '#0e0e16'
border = '#3a3a4a'
- text_color = '#666'
+ text_color = '#6C6E73'
else:
bg_top = theme['bg_top']
bg_bot = theme['bg_bot']
@@ -455,7 +455,7 @@ class AchievementsView(QDialog):
def _tabs_qss(self) -> str:
return """
QTabWidget::pane {
- background: #14141c;
+ background: #25262B;
border: 1px solid #2a2a3a;
border-radius: 10px;
top: -1px;
@@ -472,7 +472,7 @@ class AchievementsView(QDialog):
font-size: 10pt;
}
QTabBar::tab:selected {
- background: #14141c;
+ background: #25262B;
color: #ffd24a;
font-weight: bold;
border-bottom: 2px solid #ffd24a;
diff --git a/ui/calendar_view.py b/ui/calendar_view.py
index e1f6e39..7b4d514 100644
--- a/ui/calendar_view.py
+++ b/ui/calendar_view.py
@@ -55,10 +55,11 @@ class CalendarView(QDialog):
# 범례
legend_layout = QHBoxLayout()
legend_layout.setSpacing(12)
- legend_layout.addWidget(QLabel("🟢 정상"))
- legend_layout.addWidget(QLabel("🔴 연장"))
- legend_layout.addWidget(QLabel("🟡 휴가"))
- legend_layout.addWidget(QLabel("⚪ 없음"))
+ for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
+ ('#FAB005', '휴가'), ('#6C6E73', '없음')]:
+ _item = QLabel(f"● {_txt}")
+ _item.setTextFormat(Qt.RichText)
+ legend_layout.addWidget(_item)
legend_layout.addStretch()
layout.addLayout(legend_layout)
@@ -77,13 +78,13 @@ class CalendarView(QDialog):
button_layout = QHBoxLayout()
button_layout.setSpacing(6)
- self.edit_time_button = QPushButton("✏️ 시간 수정")
+ self.edit_time_button = QPushButton("시간 수정")
self.edit_time_button.setObjectName("btn_primary")
self.edit_time_button.setEnabled(False)
self.edit_time_button.clicked.connect(self.edit_work_time)
button_layout.addWidget(self.edit_time_button)
- self.delete_record_button = QPushButton("🗑️ 기록 삭제")
+ self.delete_record_button = QPushButton("기록 삭제")
self.delete_record_button.setObjectName("btn_danger")
self.delete_record_button.setEnabled(False)
self.delete_record_button.clicked.connect(self.delete_selected_record)
@@ -104,7 +105,7 @@ class CalendarView(QDialog):
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
memo_layout.addWidget(self.memo_edit)
- self.save_memo_button = QPushButton("💾 메모 저장")
+ self.save_memo_button = QPushButton("메모 저장")
self.save_memo_button.setObjectName("btn_primary")
self.save_memo_button.setEnabled(False)
self.save_memo_button.clicked.connect(self.save_memo)
@@ -163,21 +164,23 @@ class CalendarView(QDialog):
existing = self.db.get_work_record(date_str)
menu = QMenu(self)
+ edit_action = delete_action = add_action = None
if existing:
- edit_action = menu.addAction(f"✏️ {date_str} 편집")
- delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
+ edit_action = menu.addAction(f"{date_str} 편집")
+ delete_action = menu.addAction(f"{date_str} 삭제")
else:
- add_action = menu.addAction(f"➕ {date_str} 기록 추가")
+ add_action = menu.addAction(f"{date_str} 기록 추가")
action = menu.exec_(self.calendar.mapToGlobal(pos))
if action is None:
return
- if existing and action.text().startswith("✏️"):
+ # 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
+ if action == edit_action:
self._open_edit_dialog(date_str)
- elif existing and action.text().startswith("🗑️"):
+ elif action == delete_action:
self._delete_record(date_str)
- elif not existing and action.text().startswith("➕"):
+ elif action == add_action:
self._add_past_record(date_str)
def _add_past_record(self, date_str: str):
@@ -267,7 +270,7 @@ class CalendarView(QDialog):
if record:
# 상세 정보 표시
- detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
+ detail = f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n"
detail += f"출근: {record['clock_in']}\n"
if record.get('clock_out'):
@@ -303,7 +306,7 @@ class CalendarView(QDialog):
self.memo_edit.setPlainText(record.get('memo', ''))
self.save_memo_button.setEnabled(True)
else:
- self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
+ self.detail_text.setText(f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
self.edit_time_button.setEnabled(False)
self.delete_record_button.setEnabled(False)
self.memo_edit.setPlainText('')
@@ -406,7 +409,7 @@ class EditWorkTimeDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
- title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
+ title = QLabel(f"{self.date_str} 출퇴근 시간 수정")
title.setObjectName("dialog_subtitle")
layout.addWidget(title)
diff --git a/ui/chart_widget.py b/ui/chart_widget.py
index 1ca3a09..484a287 100644
--- a/ui/chart_widget.py
+++ b/ui/chart_widget.py
@@ -20,14 +20,14 @@ except ImportError:
_MPL = False
-# 다크 테마 색상 (dark_components 톤과 일치)
-_CHART_BG = '#14141c'
-_CHART_GRID = '#2a2a3a'
-_CHART_TEXT = '#c0c0d0'
-_CHART_BAR_NORMAL = '#6b9eff' # blue
-_CHART_BAR_OVERTIME = '#ff90b8' # pink
-_CHART_BAR_WEEKEND = '#fcd34d' # gold
-_CHART_AVG_LINE = '#4ade80' # green
+# 다크 테마 색상 (dark_components / styles.py 톤과 일치)
+_CHART_BG = '#25262B'
+_CHART_GRID = '#2C2E33'
+_CHART_TEXT = '#909296'
+_CHART_BAR_NORMAL = '#4DABF7' # accent blue
+_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
+_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
+_CHART_AVG_LINE = '#51CF66' # green
def _apply_dark_axes(ax) -> None:
@@ -55,7 +55,7 @@ class _Fallback(QWidget):
label = QLabel(message)
label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True)
- label.setStyleSheet("color: #888; padding: 20px;")
+ label.setStyleSheet("color: #909296; padding: 20px;")
layout.addWidget(label)
self.setLayout(layout)
diff --git a/ui/dark_components.py b/ui/dark_components.py
index a28483c..22a36ca 100644
--- a/ui/dark_components.py
+++ b/ui/dark_components.py
@@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt
# ── 색상 팔레트 ────────────────────────────────────────────────
-DARK_BG = '#0e0e14'
-DARK_PANEL = '#14141c'
-DARK_PANEL_2 = '#1c1c28'
-DARK_BORDER = '#2a2a3a'
-DARK_BORDER_STRONG = '#44446a'
-DARK_TEXT = '#e8e8f4'
-DARK_TEXT_DIM = '#a0a0b8'
-DARK_TEXT_FAINT = '#666680'
+# 메인 앱(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 = '#6b9eff'
+ACCENT_BLUE = '#4DABF7'
ACCENT_CYAN = '#4adef0'
ACCENT_PINK = '#ff90b8'
-ACCENT_GREEN = '#4ade80'
+ACCENT_GREEN = '#51CF66'
ACCENT_ORANGE = '#fcd34d'
-ACCENT_RED = '#fb7185'
+ACCENT_RED = '#FA5252'
# 카드 테마 (등급/상태별)
CARD_THEMES = {
@@ -83,7 +85,7 @@ def dialog_qss() -> str:
return f"QDialog {{ background: {DARK_BG}; }}"
-def tabs_qss(accent: str = ACCENT_GOLD) -> str:
+def tabs_qss(accent: str = ACCENT_BLUE) -> str:
return f"""
QTabWidget::pane {{
background: {DARK_PANEL};
@@ -142,36 +144,36 @@ def button_qss(variant: str = 'default') -> str:
return f"""
QPushButton {{
background: {ACCENT_BLUE}; color: white;
- border: none; border-radius: 6px;
+ border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
- QPushButton:hover {{ background: #82b0ff; }}
- QPushButton:pressed {{ background: #5a8eee; }}
- QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
+ QPushButton:hover {{ background: #69B6F8; }}
+ QPushButton:pressed {{ background: #3D97E0; }}
+ QPushButton:disabled {{ background: {DARK_PANEL_2}; color: {DARK_TEXT_FAINT}; }}
"""
if variant == 'success':
return f"""
QPushButton {{
background: {ACCENT_GREEN}; color: #0e2a1a;
- border: none; border-radius: 6px;
+ border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
- QPushButton:hover {{ background: #6ae899; }}
+ QPushButton:hover {{ background: #69DB7C; }}
"""
if variant == 'danger':
return f"""
QPushButton {{
background: {ACCENT_RED}; color: white;
- border: none; border-radius: 6px;
+ border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
- QPushButton:hover {{ background: #fc8896; }}
+ QPushButton:hover {{ background: #FF6B6B; }}
"""
if variant == 'ghost':
return f"""
QPushButton {{
background: transparent; color: {DARK_TEXT_DIM};
- border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
+ border: 1px solid {DARK_BORDER_STRONG}; border-radius: 8px;
padding: 6px 14px; font-size: 9.5pt;
}}
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
@@ -181,10 +183,10 @@ def button_qss(variant: str = 'default') -> str:
return f"""
QPushButton {{
background: {DARK_PANEL_2}; color: {DARK_TEXT};
- border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
+ border: 1px solid {DARK_BORDER_STRONG}; border-radius: 8px;
padding: 8px 18px; font-size: 10pt;
}}
- QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
+ QPushButton:hover {{ background: {DARK_BORDER_STRONG}; border-color: {ACCENT_BLUE}; }}
"""
@@ -205,10 +207,9 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
container = QFrame()
container.setStyleSheet(f"""
QFrame {{
- background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
- stop:0 #1a1a30, stop:1 #2a1a3a);
- border: 1px solid #3a3a5a;
- border-radius: 12px;
+ background: {DARK_PANEL};
+ border: 1px solid {DARK_BORDER};
+ border-radius: 8px;
}}
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
""")
@@ -228,7 +229,7 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
left.addWidget(t)
big = QLabel(
f""
- f"{big_value}" + (f""
+ f"{big_value}" + (f""
f" {subtitle}" if subtitle else '')
)
big.setTextFormat(Qt.RichText)
@@ -268,13 +269,20 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
outer.setSpacing(12)
if icon:
- icon_lbl = QLabel(icon)
- icon_lbl.setStyleSheet(
- f"font-size: 28pt; background: transparent; border: none; "
- f"color: {t['border_strong']};"
- )
+ 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()
@@ -330,11 +338,16 @@ def build_section_card(title: str, content: QWidget,
head = QHBoxLayout()
if icon:
- i = QLabel(icon)
- i.setStyleSheet(
- f"font-size: 16pt; color: {t['border_strong']}; "
- f"background: transparent; border: none;"
- )
+ 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(
diff --git a/ui/goal_widget.py b/ui/goal_widget.py
index e2f39af..8e120d0 100644
--- a/ui/goal_widget.py
+++ b/ui/goal_widget.py
@@ -20,7 +20,7 @@ class GoalWidget(QWidget):
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(4)
- title = QLabel("🎯 이번 달 목표")
+ title = QLabel("이번 달 목표")
title.setStyleSheet("font-weight: bold;")
layout.addWidget(title)
@@ -78,7 +78,7 @@ class GoalWidget(QWidget):
ot_h, ot_m = ot_total // 60, ot_total % 60
tg_h, tg_m = ot_target // 60, ot_target % 60
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
- color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
+ color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.ot_label.setVisible(False)
@@ -93,7 +93,7 @@ class GoalWidget(QWidget):
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
ratio = avg / avg_target if avg_target else 0
- color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
+ color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.avg_label.setVisible(False)
diff --git a/ui/help_view.py b/ui/help_view.py
index f9080fb..6986716 100644
--- a/ui/help_view.py
+++ b/ui/help_view.py
@@ -45,7 +45,7 @@ class HelpView(QDialog):
main_layout.setSpacing(10)
# 다크 타이틀
- title = QLabel(f"📖 {tr('window.help')}")
+ title = QLabel(tr('window.help'))
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
f"background: transparent; border: none; padding: 4px 0;"
@@ -53,7 +53,6 @@ class HelpView(QDialog):
main_layout.addWidget(title)
tabs = QTabWidget()
- tabs.setDocumentMode(True)
tabs.setStyleSheet(tabs_qss())
for html_key, tab_label_key in self._TABS:
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
@@ -63,7 +62,7 @@ class HelpView(QDialog):
button_layout.setContentsMargins(0, 6, 0, 0)
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
- onboarding_button = QPushButton("🚀 온보딩 다시 보기")
+ onboarding_button = QPushButton("온보딩 다시 보기")
onboarding_button.setMinimumHeight(36)
onboarding_button.setStyleSheet(button_qss('ghost'))
onboarding_button.clicked.connect(self._reopen_onboarding)
diff --git a/ui/icons.py b/ui/icons.py
new file mode 100644
index 0000000..d846630
--- /dev/null
+++ b/ui/icons.py
@@ -0,0 +1,82 @@
+"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
+
+이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
+(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환 시
+재호출하면 자동으로 재틴팅된다.
+
+사용:
+ from ui.icons import get_icon
+ btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
+ btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
+"""
+from __future__ import annotations
+
+from PyQt5.QtCore import QByteArray, QRectF, Qt
+from PyQt5.QtGui import QIcon, QPixmap, QPainter
+from PyQt5.QtSvg import QSvgRenderer
+
+from ui.styles import ThemeColors
+
+# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
+_PATHS = {
+ 'chart': '',
+ 'calendar': '',
+ 'report': '',
+ 'award': '',
+ 'help': '',
+ 'settings': '',
+ 'logout': '',
+ 'rotate-ccw': '',
+ 'edit': '',
+ 'clock': '',
+ 'trash': '',
+ 'flame': '',
+ 'trending-up': '',
+ 'search': '',
+ 'external-link': '',
+ 'coffee': '',
+ 'repeat': '',
+ 'home': '',
+}
+
+_SVG_TMPL = (
+ ''
+)
+
+_cache: dict = {}
+
+
+def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
+ """이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
+ if color is None:
+ color = ThemeColors.get('text_secondary')
+ key = (name, color, size)
+ cached = _cache.get(key)
+ if cached is not None:
+ return cached
+
+ paths = _PATHS.get(name)
+ if paths is None:
+ return QIcon()
+
+ svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
+ renderer = QSvgRenderer(QByteArray(svg))
+
+ dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
+ pm = QPixmap(size * dpr, size * dpr)
+ pm.fill(Qt.transparent)
+ painter = QPainter(pm)
+ renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
+ painter.end()
+ pm.setDevicePixelRatio(dpr)
+
+ icon = QIcon(pm)
+ _cache[key] = icon
+ return icon
+
+
+def clear_cache() -> None:
+ """테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
+ _cache.clear()
diff --git a/ui/leave_calendar_view.py b/ui/leave_calendar_view.py
index 4da2125..262f99a 100644
--- a/ui/leave_calendar_view.py
+++ b/ui/leave_calendar_view.py
@@ -22,7 +22,7 @@ class LeaveCalendarView(QDialog):
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
- self.setWindowTitle("📅 연차 캘린더")
+ self.setWindowTitle("연차 캘린더")
self.setModal(True)
self.setMinimumSize(540, 480)
self._build_ui()
@@ -37,7 +37,7 @@ class LeaveCalendarView(QDialog):
balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance
- title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
+ title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
@@ -45,10 +45,11 @@ class LeaveCalendarView(QDialog):
# 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout()
- for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
- "🔵 예정", "🔘 종일+예정"]:
- l = QLabel(label)
- l.setStyleSheet(f"padding: 2px 6px;")
+ for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
+ ('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
+ ('#748FFC', '종일+예정')]:
+ l = QLabel(f"● {_txt}")
+ l.setStyleSheet("padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
@@ -61,7 +62,7 @@ class LeaveCalendarView(QDialog):
# 선택 일자 정보
self.detail_label = QLabel("")
- self.detail_label.setStyleSheet("padding: 6px; color: #888;")
+ self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
layout.addWidget(self.detail_label)
# 닫기 버튼
@@ -116,4 +117,4 @@ class LeaveCalendarView(QDialog):
d = float(r.get('days') or 0)
memo = r.get('memo') or ''
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
- self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
+ self.detail_label.setText(f"{date_str}: " + ", ".join(parts))
diff --git a/ui/leave_view.py b/ui/leave_view.py
index 0fa671a..1b336af 100644
--- a/ui/leave_view.py
+++ b/ui/leave_view.py
@@ -90,7 +90,7 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
- schedule_button = QPushButton("🗓️ 스케줄")
+ schedule_button = QPushButton("스케줄")
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_button)
@@ -146,7 +146,7 @@ class LeaveView(QDialog):
days_str = f"{days}일"
days_item = QTableWidgetItem(days_str)
days_item.setTextAlignment(Qt.AlignCenter)
- days_item.setForeground(QColor(231, 76, 60)) # 빨간색
+ days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
memo_item = QTableWidgetItem(record['memo'] or "")
diff --git a/ui/main_window.py b/ui/main_window.py
index c095a16..bd3a747 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QProgressBar,
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
QShortcut, QDialog)
-from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
+from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir, QSize
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
from core.settings_keys import (
@@ -50,7 +50,7 @@ class MainWindow(QMainWindow):
super().__init__()
# 테마 적용
- self.current_theme = 'light' # 설정에서 로드 후 덮어씀
+ self.current_theme = 'dark' # 설정에서 로드 후 덮어씀
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
if db is not None:
@@ -82,7 +82,7 @@ class MainWindow(QMainWindow):
self.cached_time_format = str(settings.get(TIME_FORMAT, '24'))
# 테마 설정
- self.current_theme = str(settings.get(THEME, 'light'))
+ self.current_theme = str(settings.get(THEME, 'dark'))
self.apply_theme(self.current_theme)
self.time_calc = self._build_time_calc(settings)
@@ -234,11 +234,11 @@ class MainWindow(QMainWindow):
from core.version import __version__
from ui.i18n_runtime import register
self._app_version = __version__
- self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
+ self.setWindowTitle(f"{tr('window.main_title')} v{__version__}")
register(self, 'window.main_title', setter='setWindowTitle',
- post=lambda t: f"⏰ {t} v{__version__}")
- self.setGeometry(100, 100, 500, 620)
- self.setMinimumSize(480, 520)
+ post=lambda t: f"{t} v{__version__}")
+ self.setGeometry(100, 100, 540, 720)
+ self.setMinimumSize(500, 600)
# 외부 컨테이너 (스크롤 + 고정 하단)
from PyQt5.QtWidgets import QScrollArea
@@ -261,10 +261,10 @@ class MainWindow(QMainWindow):
outer_widget.setLayout(outer_layout)
self.setCentralWidget(outer_widget)
- # 메인 레이아웃
+ # 메인 레이아웃 — 외곽 24px, 위젯 간 12px (통일된 여백 시스템)
main_layout = QVBoxLayout()
- main_layout.setSpacing(8)
- main_layout.setContentsMargins(12, 10, 12, 10)
+ main_layout.setSpacing(12)
+ main_layout.setContentsMargins(24, 20, 24, 16)
# 1. 헤더 - 앱 타이틀
title_label = QLabel("퇴근시간 계산기")
@@ -287,16 +287,10 @@ class MainWindow(QMainWindow):
clock_in_group = self.create_clock_in_group()
main_layout.addWidget(clock_in_group)
- # 3. 남은 시간 표시 그룹
+ # 3. 남은 시간 표시 그룹 (히어로 — 남은시간 + 진행률 + 예상 퇴근시각 통합)
remaining_group = self.create_remaining_time_group()
main_layout.addWidget(remaining_group)
- # 4. 예상 퇴근시간
- self.expected_time_label = QLabel()
- self.expected_time_label.setObjectName("expected_time")
- self.expected_time_label.setAlignment(Qt.AlignCenter)
- main_layout.addWidget(self.expected_time_label)
-
# 5. 점심/저녁 토글 (가로 배치)
meal_button_layout = QHBoxLayout()
meal_button_layout.setSpacing(8)
@@ -359,8 +353,8 @@ class MainWindow(QMainWindow):
fixed_bottom = QWidget()
fixed_bottom.setObjectName("fixed_bottom")
fixed_bottom_layout = QVBoxLayout()
- fixed_bottom_layout.setSpacing(8)
- fixed_bottom_layout.setContentsMargins(12, 8, 12, 10)
+ fixed_bottom_layout.setSpacing(10)
+ fixed_bottom_layout.setContentsMargins(24, 12, 24, 16)
self.clock_out_button = QPushButton(tr('btn.clock_out'))
self.clock_out_button.setObjectName("clock_out_button")
@@ -375,7 +369,7 @@ class MainWindow(QMainWindow):
stats_button = QPushButton(tr('menu.stats'))
calendar_button = QPushButton(tr('menu.calendar'))
report_button = QPushButton(tr('menu.daily_report'))
- achievements_button = QPushButton("🏆 도전과제")
+ achievements_button = QPushButton("도전과제")
help_button = QPushButton(tr('menu.help'))
settings_button = QPushButton(tr('menu.settings'))
@@ -387,8 +381,17 @@ class MainWindow(QMainWindow):
(settings_button, 'menu.settings')]:
register(btn, key)
- for btn in [stats_button, calendar_button, report_button,
- achievements_button, help_button, settings_button]:
+ # 하단 네비게이션 — 라인 아이콘 + 라벨 (이모지 대체)
+ self._nav_icon_specs = [
+ (stats_button, 'chart'),
+ (calendar_button, 'calendar'),
+ (report_button, 'report'),
+ (achievements_button, 'award'),
+ (help_button, 'help'),
+ (settings_button, 'settings'),
+ ]
+ for btn, _name in self._nav_icon_specs:
+ btn.setObjectName("nav_btn")
bottom_layout.addWidget(btn)
# 버튼 연결
@@ -406,9 +409,27 @@ class MainWindow(QMainWindow):
# 초기 날짜 업데이트
self.update_date_label()
+ # 라인 아이콘 적용 (테마 색 틴팅)
+ self._apply_button_icons()
+
# 앱 내 단축키
self._setup_shortcuts()
+ def _apply_button_icons(self):
+ """버튼 아이콘을 현재 테마 색으로 (재)적용. 테마 전환 시에도 호출돼 재틴팅."""
+ from ui.icons import get_icon
+ sec = ThemeColors.get('text_secondary')
+ inv = ThemeColors.get('text_inverse')
+ for btn, name in getattr(self, '_nav_icon_specs', []):
+ btn.setIcon(get_icon(name, sec, 16))
+ btn.setIconSize(QSize(16, 16))
+ if getattr(self, 'edit_clock_in_button', None) is not None:
+ self.edit_clock_in_button.setIcon(get_icon('edit', sec, 15))
+ self.edit_clock_in_button.setIconSize(QSize(15, 15))
+ if getattr(self, 'clock_out_button', None) is not None:
+ self.clock_out_button.setIcon(get_icon('logout', inv, 18))
+ self.clock_out_button.setIconSize(QSize(18, 18))
+
def _setup_shortcuts(self):
"""앱 내 단축키 — 메인 창 포커스 시만 동작"""
bindings = [
@@ -425,74 +446,93 @@ class MainWindow(QMainWindow):
sc = QShortcut(QKeySequence(keyseq), self)
sc.activated.connect(handler)
+ def _build_time_column(self, label_text: str, value_widget: QLabel) -> QVBoxLayout:
+ """라벨(작게) 위 + 시각(크게) 아래 형태의 세로 컬럼. 한 줄 나란히 배치용."""
+ col = QVBoxLayout()
+ col.setSpacing(2)
+ lbl = QLabel(label_text)
+ lbl.setObjectName("field_label")
+ col.addWidget(lbl)
+ col.addWidget(value_widget)
+ return col
+
def create_clock_in_group(self) -> QGroupBox:
- """출근 정보 그룹 생성"""
+ """출근 정보 그룹 생성 — 출근/현재 시각을 한 줄에 나란히"""
group = QGroupBox("오늘의 근무")
layout = QVBoxLayout()
- layout.setSpacing(4)
- layout.setContentsMargins(12, 20, 12, 8)
+ layout.setSpacing(8)
+ layout.setContentsMargins(16, 24, 16, 16)
- # 출근 시간 레이아웃
- clock_in_layout = QHBoxLayout()
- clock_in_label = QLabel("출근:")
- clock_in_label.setObjectName("field_label")
- clock_in_label.setFixedWidth(50)
+ # 출근 / 현재 시각을 한 줄에 나란히 (2-컬럼)
+ row = QHBoxLayout()
+ row.setSpacing(12)
+
+ # 출근 컬럼 (라벨 + 편집 버튼 헤더 / 값)
self.clock_in_value = QLabel("--:--:--")
self.clock_in_value.setObjectName("time_value")
- self.clock_in_value.setMinimumWidth(90)
- # 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
+ # 라벨 자체도 클릭 가능 (인라인 편집 — 출근 시간 빠른 수정)
self.clock_in_value.setCursor(Qt.PointingHandCursor)
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
- self.edit_clock_in_button = QPushButton("✏️ 수정")
+
+ clock_in_col = QVBoxLayout()
+ clock_in_col.setSpacing(2)
+ clock_in_label = QLabel("출근")
+ clock_in_label.setObjectName("field_label")
+ clock_in_col.addWidget(clock_in_label)
+
+ # 시각 + 편집 버튼을 한 줄에 (편집 아이콘이 출근 시각 바로 옆에 붙도록)
+ clock_in_value_row = QHBoxLayout()
+ clock_in_value_row.setSpacing(6)
+ self.edit_clock_in_button = QPushButton("")
self.edit_clock_in_button.setObjectName("btn_small")
- self.edit_clock_in_button.setFixedWidth(70)
+ self.edit_clock_in_button.setFixedWidth(30)
+ self.edit_clock_in_button.setToolTip("출근 시간 수정")
self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
+ clock_in_value_row.addWidget(self.clock_in_value)
+ clock_in_value_row.addWidget(self.edit_clock_in_button)
+ clock_in_value_row.addStretch()
+ clock_in_col.addLayout(clock_in_value_row)
- clock_in_layout.addWidget(clock_in_label)
- clock_in_layout.addWidget(self.clock_in_value)
- clock_in_layout.addStretch()
- clock_in_layout.addWidget(self.edit_clock_in_button)
-
- # 현재 시간 레이아웃
- current_layout = QHBoxLayout()
- current_label = QLabel("현재:")
- current_label.setObjectName("field_label")
- current_label.setFixedWidth(50)
+ # 현재 컬럼
self.current_time_value = QLabel("--:--:--")
self.current_time_value.setObjectName("time_value")
- self.current_time_value.setMinimumWidth(90)
+ current_col = self._build_time_column("현재", self.current_time_value)
- current_layout.addWidget(current_label)
- current_layout.addWidget(self.current_time_value)
- current_layout.addStretch()
-
- layout.addLayout(clock_in_layout)
- layout.addLayout(current_layout)
+ row.addLayout(clock_in_col, 1)
+ row.addLayout(current_col, 1)
+ layout.addLayout(row)
group.setLayout(layout)
return group
def create_remaining_time_group(self) -> QGroupBox:
- """남은 시간 표시 그룹 생성"""
+ """남은 시간 히어로 그룹 — 남은시간(가장 큼) + 진행률 + 예상 퇴근시각"""
self.remaining_time_group = QGroupBox("남은 시간")
layout = QVBoxLayout()
- layout.setSpacing(6)
- layout.setContentsMargins(12, 20, 12, 8)
+ layout.setSpacing(12)
+ layout.setContentsMargins(16, 24, 16, 16)
- # 남은 시간 라벨
+ # 남은 시간 라벨 (히어로 — 화면에서 가장 큰 결과)
self.remaining_time_label = QLabel("--:--:--")
self.remaining_time_label.setObjectName("time_display")
self.remaining_time_label.setAlignment(Qt.AlignCenter)
- # 프로그레스 바
+ # 프로그레스 바 (얇게 6px)
self.progress_bar = QProgressBar()
self.progress_bar.setTextVisible(False)
+ self.progress_bar.setFixedHeight(6)
+
+ # 예상 퇴근시각 (히어로 카드 내부에 통합)
+ self.expected_time_label = QLabel()
+ self.expected_time_label.setObjectName("expected_time")
+ self.expected_time_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.remaining_time_label)
layout.addWidget(self.progress_bar)
+ layout.addWidget(self.expected_time_label)
self.remaining_time_group.setLayout(layout)
return self.remaining_time_group
@@ -502,8 +542,8 @@ class MainWindow(QMainWindow):
group = QGroupBox("연장근무 및 연차 현황")
layout = QVBoxLayout()
- layout.setSpacing(6)
- layout.setContentsMargins(12, 20, 12, 8)
+ layout.setSpacing(10)
+ layout.setContentsMargins(16, 24, 16, 16)
# 연장근무 섹션
overtime_header = QHBoxLayout()
@@ -621,14 +661,14 @@ class MainWindow(QMainWindow):
if today_record.get('clock_out'):
self.is_clocked_in = False
self.clock_out_button.setEnabled(True)
- self.clock_out_button.setText("🔄 퇴근 취소")
+ self.clock_out_button.setText("퇴근 취소")
# 퇴근 완료 상태에서도 출퇴근 시간은 표시
self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True))
else:
# 출근 중이면 퇴근하기 버튼
self.clock_out_button.setEnabled(True)
- self.clock_out_button.setText("✅ 퇴근하기")
+ self.clock_out_button.setText("퇴근하기")
else:
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
@@ -799,17 +839,14 @@ class MainWindow(QMainWindow):
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
remaining_str = f"+{hours:02d}:{minutes:02d}:{seconds:02d}"
- self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_overtime')};")
+ # 퇴근 가능(연장근무 진입) → 그린 피드백
+ self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};")
else:
- # 정상 근무 중
+ # 정상 근무 중 — 아직 퇴근 전이므로 기본 텍스트 색
self.remaining_time_group.setTitle("남은 시간")
remaining_str = self.time_calc.format_time_delta(remaining)
-
- if remaining.total_seconds() < 1800: # 30분 이내
- self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_warning')};")
- else:
- self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};")
+ self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('text_primary')};")
self._set_text_if_changed(self.remaining_time_label, remaining_str)
@@ -885,7 +922,7 @@ class MainWindow(QMainWindow):
label = '연차'
memo = ''
- self.remaining_time_group.setTitle("🌴 오늘은 휴가")
+ self.remaining_time_group.setTitle("오늘은 휴가")
self.remaining_time_label.setText("연차 사용 중")
self.remaining_time_label.setStyleSheet(
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
@@ -893,10 +930,10 @@ class MainWindow(QMainWindow):
self.progress_bar.setValue(100)
if memo:
self._set_text_if_changed(self.expected_time_label,
- f"🌴 {label} — {memo}")
+ f"{label} — {memo}")
else:
self._set_text_if_changed(self.expected_time_label,
- f"🌴 {label}")
+ f"{label}")
# 트레이/미니 위젯
self.tray_icon.update_time_display("🌴 휴가")
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
@@ -1301,7 +1338,7 @@ class MainWindow(QMainWindow):
self.is_clocked_in = False
self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋
self.clock_out_button.setEnabled(True)
- self.clock_out_button.setText("🔄 퇴근 취소")
+ self.clock_out_button.setText("퇴근 취소")
# 결과 메시지
msg = f"퇴근 처리되었습니다!\n\n"
@@ -1354,7 +1391,7 @@ class MainWindow(QMainWindow):
# 상태 복원
self.is_clocked_in = True
self.clock_out_button.setEnabled(True)
- self.clock_out_button.setText("✅ 퇴근하기")
+ self.clock_out_button.setText("퇴근하기")
# 잔액 업데이트
self.update_overtime_balance()
@@ -1378,6 +1415,18 @@ class MainWindow(QMainWindow):
f"퇴근 취소 중 오류가 발생했습니다:\n{str(e)}"
)
+ def _apply_auto_overtime_gate(self, overtime_earned: int) -> int:
+ """자동 적립(auto_overtime) 설정을 존중해 적립분을 게이팅.
+
+ OFF면 0을 반환해 은행 적립(add_overtime_earned)을 건너뛰게 한다.
+ clock_out()은 대화상자로 직접 확인하지만, 자동 퇴근 경로(롤오버 / 이전일
+ 자동 처리)는 사용자 상호작용 시점이 없으므로 설정만으로 동일하게 게이팅한다.
+ 실제 연장(work_records.overtime_minutes)은 그대로 기록되고 적립만 스킵된다.
+ """
+ if overtime_earned > 0 and not self.db.get_setting_bool('auto_overtime', True):
+ return 0
+ return overtime_earned
+
def handle_workday_rollover(self, now: datetime):
"""근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근
@@ -1451,6 +1500,9 @@ class MainWindow(QMainWindow):
unit_minutes=unit_minutes,
)
+ # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
+ overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
+
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
self.db.update_clock_out(
workday_str, before_boundary_str, total_hours,
@@ -1537,7 +1589,7 @@ class MainWindow(QMainWindow):
self.clock_in_time = None
self.is_clocked_in = False
self.clock_out_button.setEnabled(False)
- self.clock_out_button.setText("✅ 퇴근하기")
+ self.clock_out_button.setText("퇴근하기")
def auto_clock_out_previous_days(self):
"""이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록"""
@@ -1609,6 +1661,9 @@ class MainWindow(QMainWindow):
unit_minutes=unit_minutes,
)
+ # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
+ overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
+
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
clock_out_str = shutdown_time.strftime("%H:%M:%S")
self.db.update_clock_out(
@@ -1678,6 +1733,9 @@ class MainWindow(QMainWindow):
unit_minutes=unit_minutes,
)
+ # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
+ overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
+
# DB 업데이트
self.db.update_clock_out(
check_date, "23:59:59", total_hours,
@@ -1749,7 +1807,7 @@ class MainWindow(QMainWindow):
# UI 업데이트
self.clock_in_value.setText(clock_in_str)
self.clock_out_button.setEnabled(True)
- self.clock_out_button.setText("✅ 퇴근하기")
+ self.clock_out_button.setText("퇴근하기")
QMessageBox.information(
self,
@@ -1794,8 +1852,15 @@ class MainWindow(QMainWindow):
def apply_theme(self, theme_name: str):
"""테마 적용"""
self.current_theme = theme_name
+ ThemeColors.set_theme(theme_name)
self.setStyleSheet(get_theme(theme_name))
apply_dark_titlebar(self, theme_name == 'dark')
+ # 버튼 아이콘을 새 테마 색으로 재틴팅 (init_ui 이후에만)
+ if hasattr(self, '_nav_icon_specs'):
+ self._apply_button_icons()
+ # 트레이 메뉴도 새 테마 QSS/아이콘으로 갱신
+ if getattr(self, 'tray_icon', None) is not None:
+ self.tray_icon.refresh_theme()
# 타이틀바 갱신을 위해 크기 미세 조정
size = self.size()
self.resize(size.width() + 1, size.height())
@@ -1806,7 +1871,7 @@ class MainWindow(QMainWindow):
dialog = SettingsView(self, self.db)
dialog.exec_()
# 설정 변경 후 테마 재적용
- new_theme = str(self.db.get_setting(THEME, 'light'))
+ new_theme = str(self.db.get_setting(THEME, 'dark'))
if new_theme != self.current_theme:
self.apply_theme(new_theme)
@@ -1834,7 +1899,7 @@ class MainWindow(QMainWindow):
from ui.meal_time_dialog import MealTimeDialog
menu = QMenu(self)
title = "점심" if meal_type == 'lunch' else "저녁"
- edit_action = menu.addAction(f"⏱ {title} 실제 시간 입력...")
+ edit_action = menu.addAction(f"{title} 실제 시간 입력...")
global_pos = button.mapToGlobal(pos)
action = menu.exec_(global_pos)
if action != edit_action:
diff --git a/ui/meal_time_dialog.py b/ui/meal_time_dialog.py
index 0d93605..3cdbe94 100644
--- a/ui/meal_time_dialog.py
+++ b/ui/meal_time_dialog.py
@@ -50,7 +50,7 @@ class MealTimeDialog(QDialog):
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
info = QLabel(info_text)
info.setWordWrap(True)
- info.setStyleSheet("color: #888; padding-bottom: 6px;")
+ info.setStyleSheet("color: #909296; padding-bottom: 6px;")
layout.addWidget(info)
# 합리적 기본값: 출근 이후로 보정
@@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
# 미리보기 라벨
self.preview = QLabel("")
- self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
+ self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
layout.addWidget(self.preview)
self._update_preview()
self.start_edit.timeChanged.connect(self._update_preview)
@@ -137,11 +137,11 @@ class MealTimeDialog(QDialog):
start_dt, end_dt, minutes = self._resolve_meal_window()
ok, reason = self._validate_window(start_dt, end_dt, minutes)
if not ok:
- self.preview.setText(f"⚠️ {reason}")
- self.preview.setStyleSheet("color: #f44336;")
+ self.preview.setText(f"{reason}")
+ self.preview.setStyleSheet("color: #FA5252;")
else:
self.preview.setText(f"총 {minutes}분")
- self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
+ self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
def _validate_window(self, start_dt: datetime, end_dt: datetime,
minutes: int) -> tuple[bool, str]:
diff --git a/ui/mini_widget.py b/ui/mini_widget.py
index 189a0c8..aa579af 100644
--- a/ui/mini_widget.py
+++ b/ui/mini_widget.py
@@ -41,7 +41,7 @@ class MiniWidget(QWidget):
self.title_label = QLabel(tr('label.remaining'))
self.title_label.setAlignment(Qt.AlignCenter)
- self.title_label.setStyleSheet("color: #888; font-size: 11px;")
+ self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
self.time_label = QLabel("--:--:--")
self.time_label.setAlignment(Qt.AlignCenter)
@@ -51,10 +51,10 @@ class MiniWidget(QWidget):
layout.addWidget(self.time_label)
self.setLayout(layout)
- # 기본 스타일 (테마 무관 가독성 유지)
+ # 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
self.setStyleSheet("""
- QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
- QLabel { color: #fff; }
+ QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
+ QLabel { color: #E9ECEF; background: transparent; }
""")
apply_dark_titlebar(self)
@@ -63,11 +63,12 @@ class MiniWidget(QWidget):
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
self.time_label.setText(remaining_str)
if remaining_str.startswith('+'):
+ # 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
self.title_label.setText(tr('label.overtime_progress'))
- self.time_label.setStyleSheet("color: #ff6b6b;")
+ self.time_label.setStyleSheet("color: #51CF66;")
else:
self.title_label.setText(tr('label.remaining'))
- self.time_label.setStyleSheet("color: #fff;")
+ self.time_label.setStyleSheet("color: #E9ECEF;")
# 드래그 이동
def mousePressEvent(self, event: QMouseEvent):
@@ -90,6 +91,17 @@ class MiniWidget(QWidget):
def contextMenuEvent(self, event):
from PyQt5.QtWidgets import QMenu
menu = QMenu(self)
+ # 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
+ # 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
+ qss = self.parent_window.styleSheet() if self.parent_window else ''
+ if not qss:
+ try:
+ from ui.styles import get_theme
+ qss = get_theme('dark')
+ except Exception:
+ qss = ''
+ if qss:
+ menu.setStyleSheet(qss)
open_main = menu.addAction("메인 창 열기")
close_mini = menu.addAction("미니 위젯 닫기")
action = menu.exec_(event.globalPos())
diff --git a/ui/onboarding_view.py b/ui/onboarding_view.py
index 5a1f367..a919cb5 100644
--- a/ui/onboarding_view.py
+++ b/ui/onboarding_view.py
@@ -32,7 +32,7 @@ WORK_PRESETS = [
class WelcomePage(QWizardPage):
def __init__(self):
super().__init__()
- self.setTitle("👋 환영합니다!")
+ self.setTitle("환영합니다!")
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
layout = QVBoxLayout()
intro = QLabel(
@@ -51,7 +51,7 @@ class WelcomePage(QWizardPage):
class WorkPatternPage(QWizardPage):
def __init__(self):
super().__init__()
- self.setTitle("🕘 근무 패턴")
+ self.setTitle("근무 패턴")
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
layout = QVBoxLayout()
@@ -127,7 +127,7 @@ class WorkPatternPage(QWizardPage):
class ClockInDetectionPage(QWizardPage):
def __init__(self):
super().__init__()
- self.setTitle("⏰ 출근 시간 감지 방식")
+ self.setTitle("출근 시간 감지 방식")
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
layout = QVBoxLayout()
@@ -139,10 +139,10 @@ class ClockInDetectionPage(QWizardPage):
layout.addWidget(opt)
info = QLabel(
- "\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
+ "\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True)
- info.setStyleSheet("color: #888; padding: 8px;")
+ info.setStyleSheet("color: #909296; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
@@ -159,7 +159,7 @@ class ClockInDetectionPage(QWizardPage):
class LeaveSalaryPage(QWizardPage):
def __init__(self):
super().__init__()
- self.setTitle("🌴 연차 + 💰 급여 (옵션)")
+ self.setTitle("연차 + 급여 (옵션)")
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
layout = QVBoxLayout()
@@ -218,7 +218,7 @@ class LeaveSalaryPage(QWizardPage):
class DiscordPage(QWizardPage):
def __init__(self):
super().__init__()
- self.setTitle("💬 Discord 알림 (선택)")
+ self.setTitle("Discord 알림 (선택)")
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
layout = QVBoxLayout()
@@ -236,7 +236,7 @@ class DiscordPage(QWizardPage):
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
- guide.setStyleSheet("color: #888; padding: 6px;")
+ guide.setStyleSheet("color: #909296; padding: 6px;")
guide.setWordWrap(True)
layout.addWidget(guide)
@@ -277,14 +277,14 @@ class DiscordPage(QWizardPage):
class FinishPage(QWizardPage):
def __init__(self):
super().__init__()
- self.setTitle("🎉 준비 완료!")
+ self.setTitle("준비 완료!")
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
layout = QVBoxLayout()
msg = QLabel(
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
- "🕐 단축키:\n"
+ "단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
diff --git a/ui/overtime_view.py b/ui/overtime_view.py
index aea2bb4..27dadec 100644
--- a/ui/overtime_view.py
+++ b/ui/overtime_view.py
@@ -66,6 +66,8 @@ class OvertimeView(QDialog):
self.earned_table.setAlternatingRowColors(True)
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
+ self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
@@ -126,7 +128,7 @@ class OvertimeView(QDialog):
cursor = conn.cursor()
cursor.execute('''
- SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
+ SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC
@@ -138,6 +140,7 @@ class OvertimeView(QDialog):
for i, record in enumerate(earned_records):
date_item = QTableWidgetItem(record[0])
date_item.setTextAlignment(Qt.AlignCenter)
+ date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
minutes = record[1]
hours = minutes // 60
@@ -148,7 +151,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
- time_item.setForeground(QColor(39, 174, 96)) # 초록색
+ time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "")
@@ -183,7 +186,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
- time_item.setForeground(QColor(231, 76, 60)) # 빨간색
+ time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
reason_item = QTableWidgetItem(record[3] or "")
@@ -249,6 +252,46 @@ class OvertimeView(QDialog):
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
+ def show_earned_context_menu(self, position):
+ """적립 내역 우클릭 메뉴 (삭제)."""
+ selected_rows = self.earned_table.selectionModel().selectedRows()
+ if not selected_rows:
+ return
+ menu = QMenu(self)
+ delete_action = QAction(tr('view.overtime.menu_delete'), self)
+ delete_action.triggered.connect(self.delete_earned_record)
+ menu.addAction(delete_action)
+ menu.exec_(self.earned_table.viewport().mapToGlobal(position))
+
+ def delete_earned_record(self):
+ """적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
+ selected_rows = self.earned_table.selectionModel().selectedRows()
+ if not selected_rows:
+ return
+ row = selected_rows[0].row()
+ date_item = self.earned_table.item(row, 0)
+ time_item = self.earned_table.item(row, 1)
+
+ # 행에 저장된 overtime_bank.id
+ bank_id = date_item.data(Qt.UserRole)
+ if bank_id is None:
+ return
+
+ reply = QMessageBox.question(
+ self,
+ tr('msg.confirm_delete.title'),
+ tr('view.overtime.delete_earned_confirm_body',
+ date=date_item.text(), time=time_item.text()),
+ QMessageBox.Yes | QMessageBox.No
+ )
+
+ if reply == QMessageBox.Yes:
+ self.db.delete_overtime_earned(bank_id)
+ self.load_data()
+ # 부모 윈도우 잔액 업데이트
+ if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
+ self.parent().update_overtime_balance()
+
def add_earned_record(self):
"""수동 적립 추가"""
dialog = AddOvertimeEarnedDialog(self, self.db)
diff --git a/ui/past_record_dialog.py b/ui/past_record_dialog.py
index a0263bb..ce6a215 100644
--- a/ui/past_record_dialog.py
+++ b/ui/past_record_dialog.py
@@ -26,7 +26,7 @@ class PastRecordDialog(QDialog):
layout.setSpacing(8)
layout.setContentsMargins(20, 16, 20, 16)
- info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
+ info = QLabel(f"{date_str} 근무 기록을 입력하세요.")
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
layout.addWidget(info)
@@ -56,9 +56,9 @@ class PastRecordDialog(QDialog):
# 점심/저녁
meal_row = QHBoxLayout()
- self.lunch_check = QCheckBox("🍱 점심시간 포함")
+ self.lunch_check = QCheckBox("점심시간 포함")
self.lunch_check.setChecked(True)
- self.dinner_check = QCheckBox("🍽 저녁시간 포함")
+ self.dinner_check = QCheckBox("저녁시간 포함")
meal_row.addWidget(self.lunch_check)
meal_row.addWidget(self.dinner_check)
meal_row.addStretch()
diff --git a/ui/recurring_leave_dialog.py b/ui/recurring_leave_dialog.py
index ee9147f..90e8f10 100644
--- a/ui/recurring_leave_dialog.py
+++ b/ui/recurring_leave_dialog.py
@@ -27,7 +27,7 @@ class RecurringLeaveDialog(QDialog):
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
- self.setWindowTitle("🔁 반복 연차 관리")
+ self.setWindowTitle("반복 연차 관리")
self.setMinimumSize(540, 480)
self._build_ui()
self._reload_list()
@@ -129,7 +129,7 @@ class RecurringLeaveDialog(QDialog):
ag.addLayout(memo_row)
# 추가 버튼
- add_btn = QPushButton("➕ 추가")
+ add_btn = QPushButton("추가")
add_btn.setObjectName("btn_primary")
add_btn.clicked.connect(self._save)
ag.addWidget(add_btn)
diff --git a/ui/schedule_view.py b/ui/schedule_view.py
index f755069..863ea70 100644
--- a/ui/schedule_view.py
+++ b/ui/schedule_view.py
@@ -38,7 +38,7 @@ class ScheduleView(QDialog):
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
- self.setWindowTitle("🗓️ 스케줄")
+ self.setWindowTitle("스케줄")
self.setMinimumSize(820, 560)
self._build_ui()
self._reload()
@@ -54,11 +54,11 @@ class ScheduleView(QDialog):
bar.addWidget(title)
bar.addStretch()
- rec_btn = QPushButton("🔁 반복 패턴 관리")
+ rec_btn = QPushButton("반복 패턴 관리")
rec_btn.clicked.connect(self._open_recurring_dialog)
bar.addWidget(rec_btn)
- add_btn = QPushButton("➕ 연차 등록")
+ add_btn = QPushButton("연차 등록")
add_btn.clicked.connect(self._open_add_leave_dialog)
bar.addWidget(add_btn)
@@ -196,11 +196,11 @@ class ScheduleView(QDialog):
# 휴일
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
if holiday:
- item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
+ item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}")
item.setForeground(QBrush(QColor("#e53935")))
self.detail_list.addItem(item)
elif d.weekday() in (5, 6):
- item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
+ item = QListWidgetItem(f"주말 ({weekday_kr[d.weekday()]}요일)")
self.detail_list.addItem(item)
# 연차 (구체)
@@ -208,7 +208,7 @@ class ScheduleView(QDialog):
days = float(r.get('days') or 0)
t = r.get('leave_type', '연차')
memo = r.get('memo') or ''
- label = f"📌 {t} {days}일"
+ label = f"{t} {days}일"
if memo:
label += f" — {memo}"
label += f" [id={r['id']}]"
@@ -221,7 +221,7 @@ class ScheduleView(QDialog):
from core.recurring_leaves import expand_for_date
for occ in expand_for_date(recurring, d):
item = QListWidgetItem(
- f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
+ f"{describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
)
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
self.detail_list.addItem(item)
diff --git a/ui/settings_view.py b/ui/settings_view.py
index 1eaa310..d353a8a 100644
--- a/ui/settings_view.py
+++ b/ui/settings_view.py
@@ -516,7 +516,7 @@ class SettingsView(QDialog):
def create_goal_group(self) -> QGroupBox:
"""월간 목표 설정 그룹 (0=비활성)."""
- group = QGroupBox("🎯 월간 목표 (0=비활성)")
+ group = QGroupBox("월간 목표 (0=비활성)")
layout = QVBoxLayout()
layout.setSpacing(6)
@@ -865,7 +865,7 @@ class SettingsView(QDialog):
# CSV 가져오기
import_layout = QHBoxLayout()
- import_btn = QPushButton("📥 CSV 가져오기")
+ import_btn = QPushButton("CSV 가져오기")
import_btn.setObjectName("btn_small")
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
import_btn.clicked.connect(self._import_csv)
@@ -1068,7 +1068,7 @@ class SettingsView(QDialog):
self.time_format_combo.setCurrentIndex(index)
# 테마
- self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
+ self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
# 언어 선택 적용
if hasattr(self, 'language_combo'):
diff --git a/ui/stats_view.py b/ui/stats_view.py
index 35173bc..310137e 100644
--- a/ui/stats_view.py
+++ b/ui/stats_view.py
@@ -42,7 +42,7 @@ class StatsView(QDialog):
layout.setContentsMargins(20, 16, 20, 14)
# 다크 톤 타이틀
- title = QLabel(f"📊 {tr('stats.title')}")
+ title = QLabel(f"{tr('stats.title')}")
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
f"background: transparent; border: none; padding: 4px 0;"
@@ -94,13 +94,13 @@ class StatsView(QDialog):
cards_row = QHBoxLayout()
cards_row.setSpacing(10)
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
- theme='blue', icon='⏱️')
+ theme='blue', icon='clock')
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
- theme='cyan', icon='📅')
+ theme='cyan', icon='calendar')
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
- theme='green', icon='📊')
+ theme='green', icon='chart')
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
- theme='gold', icon='🔥')
+ theme='gold', icon='flame')
for c in (self.weekly_total_card, self.weekly_days_card,
self.weekly_avg_card, self.weekly_ot_card):
cards_row.addWidget(c, 1)
@@ -110,7 +110,7 @@ class StatsView(QDialog):
from ui.chart_widget import make_chart_widget
self.weekly_chart = make_chart_widget(widget)
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
- theme='gray', icon='📈')
+ theme='gray', icon='trending-up')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
@@ -128,13 +128,13 @@ class StatsView(QDialog):
cards_row = QHBoxLayout()
cards_row.setSpacing(10)
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
- theme='blue', icon='⏱️')
+ theme='blue', icon='clock')
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
- theme='cyan', icon='📅')
+ theme='cyan', icon='calendar')
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
- theme='green', icon='📊')
+ theme='green', icon='chart')
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
- theme='gold', icon='🔥')
+ theme='gold', icon='flame')
for c in (self.monthly_total_card, self.monthly_days_card,
self.monthly_avg_card, self.monthly_ot_card):
cards_row.addWidget(c, 1)
@@ -160,7 +160,7 @@ class StatsView(QDialog):
from ui.chart_widget import make_chart_widget
self.monthly_chart = make_chart_widget(widget)
chart_card = build_section_card("요일별 평균", self.monthly_chart,
- theme='gray', icon='📊')
+ theme='gray', icon='chart')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
@@ -183,13 +183,13 @@ class StatsView(QDialog):
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
- theme='cyan', icon='🔍'))
+ theme='cyan', icon='search'))
# 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
- theme='gray', icon='⏰'), 1)
+ theme='gray', icon='clock'), 1)
widget.setLayout(layout)
return widget
diff --git a/ui/styles.py b/ui/styles.py
index ff6aac3..6b3d8a8 100644
--- a/ui/styles.py
+++ b/ui/styles.py
@@ -33,8 +33,8 @@ def _ensure_icons():
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)]),
+ ('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
+ ('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
]:
path = os.path.join(_arrow_dir, f'{name}.png')
if not os.path.exists(path):
@@ -78,6 +78,9 @@ LIGHT_COLORS = {
'bg_primary': '#F5F5F7',
'bg_secondary': '#FFFFFF',
'bg_tertiary': '#EDEDF0',
+ # 인터랙션 표면
+ 'surface_hover': '#E2E3E7',
+ 'surface_pressed': '#D5D6DB',
# 텍스트 계층
'text_primary': '#1A1A2E',
'text_secondary': '#4A4A68',
@@ -85,9 +88,15 @@ LIGHT_COLORS = {
'text_inverse': '#FFFFFF',
# 액센트
'accent_primary': '#3B82F6',
+ 'accent_primary_hover': '#2F74EE',
+ 'accent_primary_pressed': '#2563EB',
'accent_success': '#10B981',
+ 'accent_success_hover': '#0EA372',
+ 'accent_success_pressed': '#0C8F63',
'accent_warning': '#F59E0B',
'accent_danger': '#EF4444',
+ 'accent_danger_hover': '#DC2626',
+ 'accent_danger_pressed': '#B91C1C',
# 테두리
'border_subtle': '#E5E7EB',
'border_default': '#D1D5DB',
@@ -120,40 +129,58 @@ LIGHT_COLORS = {
}
DARK_COLORS = {
- 'bg_primary': '#111118',
- 'bg_secondary': '#1C1C2E',
- 'bg_tertiary': '#282842',
- 'text_primary': '#ECECF4',
- 'text_secondary': '#B0B0C8',
- 'text_tertiary': '#808098',
+ # 배경 계층 — 모던 다크 (Notion/Linear 톤)
+ 'bg_primary': '#1A1B1E', # 앱 배경
+ 'bg_secondary': '#25262B', # 카드 / 패널
+ 'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
+ # 인터랙션 표면
+ 'surface_hover': '#34363D',
+ 'surface_pressed': '#3A3D44',
+ # 텍스트 계층
+ 'text_primary': '#E9ECEF',
+ 'text_secondary': '#909296',
+ 'text_tertiary': '#6C6E73',
'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',
+ # 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
+ 'accent_primary': '#4DABF7',
+ 'accent_primary_hover': '#69B6F8',
+ 'accent_primary_pressed': '#3D97E0',
+ 'accent_success': '#51CF66',
+ 'accent_success_hover': '#69DB7C',
+ 'accent_success_pressed': '#43B85A',
+ 'accent_warning': '#FAB005',
+ 'accent_danger': '#FA5252',
+ 'accent_danger_hover': '#FF6B6B',
+ 'accent_danger_pressed': '#E64545',
+ # 테두리
+ 'border_subtle': '#2C2E33',
+ 'border_default': '#373A40',
+ 'border_focus': '#4DABF7',
+ # 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
+ 'badge_overtime_bg': '#2C2E33',
+ 'badge_overtime_text': '#FAB005',
+ 'badge_leave_bg': '#2C2E33',
+ 'badge_leave_text': '#4DABF7',
+ 'badge_total_bg': '#2C2E33',
+ 'badge_total_text': '#51CF66',
+ # 프로그레스 — 단일 accent 솔리드
+ 'progress_bg': '#2C2E33',
+ 'progress_start': '#4DABF7',
+ 'progress_end': '#4DABF7',
+ # 상태 색상 (동적 텍스트 피드백)
+ 'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
+ 'status_warning': '#FAB005',
+ 'status_normal': '#51CF66',
+ 'status_break_active': '#FA5252',
+ 'status_break_idle': '#6C6E73',
+ # 캘린더 날짜 배경 — 미묘한 다크 틴트
+ 'cal_normal': '#1E3A2A',
+ 'cal_overtime': '#3A2122',
+ 'cal_incomplete': '#3A331E',
+ # 스크롤바
+ 'scrollbar_bg': '#1A1B1E',
+ 'scrollbar_handle': '#373A40',
+ 'scrollbar_hover': '#4DABF7',
}
@@ -192,7 +219,7 @@ QMainWindow, QDialog {{
}}
QWidget {{
- font-family: "Segoe UI", "맑은 고딕", sans-serif;
+ font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
font-size: 9.5pt;
color: {c['text_primary']};
}}
@@ -206,14 +233,14 @@ QWidget#central_widget {{
════════════════════════════════════════ */
QLabel#app_title {{
- font-size: 12pt;
+ font-size: 13pt;
font-weight: bold;
color: {c['text_primary']};
padding: 2px;
}}
QLabel#date_label {{
- font-size: 9pt;
+ font-size: 9.5pt;
color: {c['text_secondary']};
padding-bottom: 4px;
}}
@@ -221,7 +248,7 @@ QLabel#date_label {{
QLabel#section_title {{
font-size: 9.5pt;
font-weight: bold;
- color: {c['text_primary']};
+ color: {c['text_secondary']};
}}
QLabel#field_label {{
@@ -229,29 +256,30 @@ QLabel#field_label {{
color: {c['text_secondary']};
}}
+/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
QLabel#time_value {{
font-family: "Consolas", "D2Coding", monospace;
- font-size: 11pt;
+ font-size: 15pt;
font-weight: bold;
color: {c['text_primary']};
}}
+/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
QLabel#time_display {{
font-family: "Consolas", "D2Coding", monospace;
- font-size: 22pt;
+ font-size: 30pt;
font-weight: bold;
color: {c['text_primary']};
- background: {c['bg_secondary']};
- border: 1px solid {c['border_subtle']};
- border-radius: 10px;
- padding: 10px;
+ background: transparent;
+ border: none;
+ padding: 4px 0;
}}
QLabel#expected_time {{
- font-size: 10pt;
+ font-size: 11.5pt;
font-weight: bold;
- color: {c['text_primary']};
- padding: 4px;
+ color: {c['text_secondary']};
+ padding: 2px;
}}
QLabel#dialog_title {{
@@ -295,7 +323,7 @@ QLabel#badge_overtime {{
qproperty-alignment: AlignCenter;
background: {c['badge_overtime_bg']};
color: {c['badge_overtime_text']};
- border-radius: 6px;
+ border-radius: 8px;
}}
QLabel#badge_leave {{
@@ -306,7 +334,7 @@ QLabel#badge_leave {{
qproperty-alignment: AlignCenter;
background: {c['badge_leave_bg']};
color: {c['badge_leave_text']};
- border-radius: 6px;
+ border-radius: 8px;
}}
QLabel#badge_total {{
@@ -317,7 +345,7 @@ QLabel#badge_total {{
qproperty-alignment: AlignCenter;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
- border-radius: 6px;
+ border-radius: 8px;
}}
QLabel#badge_balance {{
@@ -326,7 +354,7 @@ QLabel#badge_balance {{
padding: 10px;
background: {c['bg_tertiary']};
color: {c['text_primary']};
- border-radius: 6px;
+ border-radius: 8px;
}}
QLabel#badge_success {{
@@ -335,7 +363,7 @@ QLabel#badge_success {{
padding: 8px;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
- border-radius: 6px;
+ border-radius: 8px;
}}
/* ════════════════════════════════════════
@@ -355,9 +383,9 @@ QLabel#separator {{
QGroupBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
- border-radius: 10px;
+ border-radius: 8px;
margin-top: 10px;
- padding: 14px;
+ padding: 16px;
padding-top: 28px;
font-size: 9.5pt;
color: {c['text_primary']};
@@ -378,52 +406,55 @@ QGroupBox::title {{
버튼
════════════════════════════════════════ */
+/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
QPushButton {{
background: {c['bg_tertiary']};
color: {c['text_primary']};
- border: 1px solid {c['border_default']};
- border-radius: 6px;
- padding: 7px 14px;
+ border: none;
+ border-radius: 8px;
+ padding: 8px 14px;
font-size: 9pt;
}}
QPushButton:hover {{
- background: {c['border_default']};
+ background: {c['surface_hover']};
}}
QPushButton:pressed {{
- background: {c['border_subtle']};
+ background: {c['surface_pressed']};
}}
QPushButton:disabled {{
- background: {c['bg_tertiary']};
+ background: {c['bg_secondary']};
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:focus {{
+ outline: none;
+}}
+
+/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
QPushButton#clock_out_button {{
- background: {c['accent_success']};
+ background: {c['accent_primary']};
color: {c['text_inverse']};
font-size: 11pt;
font-weight: bold;
- padding: 8px;
+ padding: 11px;
border: none;
border-radius: 8px;
}}
QPushButton#clock_out_button:hover {{
- background: {'#0EA572' if not is_dark else '#2BB885'};
+ background: {c['accent_primary_hover']};
}}
QPushButton#clock_out_button:pressed {{
- background: {'#0C8F63' if not is_dark else '#28A87A'};
+ background: {c['accent_primary_pressed']};
}}
/* 주요 액션 버튼 */
@@ -435,11 +466,11 @@ QPushButton#btn_primary {{
}}
QPushButton#btn_primary:hover {{
- background: {c['accent_primary']}DD;
+ background: {c['accent_primary_hover']};
}}
QPushButton#btn_primary:pressed {{
- background: {c['accent_primary']}BB;
+ background: {c['accent_primary_pressed']};
}}
/* 위험 버튼 */
@@ -450,11 +481,11 @@ QPushButton#btn_danger {{
}}
QPushButton#btn_danger:hover {{
- background: {c['accent_danger']}DD;
+ background: {c['accent_danger_hover']};
}}
QPushButton#btn_danger:pressed {{
- background: {c['accent_danger']}BB;
+ background: {c['accent_danger_pressed']};
}}
/* 성공 버튼 */
@@ -465,25 +496,44 @@ QPushButton#btn_success {{
}}
QPushButton#btn_success:hover {{
- background: {c['accent_success']}DD;
+ background: {c['accent_success_hover']};
}}
QPushButton#btn_success:pressed {{
- background: {c['accent_success']}BB;
+ background: {c['accent_success_pressed']};
}}
-/* 작은 버튼 */
+/* 작은 버튼 — 미묘한 표면 */
QPushButton#btn_small {{
font-size: 8.5pt;
- padding: 5px 10px;
+ padding: 6px 10px;
}}
QPushButton#btn_small:hover {{
- background: {c['accent_primary']}20;
+ background: {c['surface_hover']};
}}
QPushButton#btn_small:pressed {{
- background: {c['accent_primary']}35;
+ background: {c['surface_pressed']};
+}}
+
+/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */
+QPushButton#nav_btn {{
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ padding: 8px 4px;
+ font-size: 8.5pt;
+ color: {c['text_secondary']};
+}}
+
+QPushButton#nav_btn:hover {{
+ background: {c['surface_hover']};
+ color: {c['text_primary']};
+}}
+
+QPushButton#nav_btn:pressed {{
+ background: {c['surface_pressed']};
}}
/* ════════════════════════════════════════
@@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
QLineEdit, QTextEdit, QComboBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
- border-radius: 6px;
+ border-radius: 8px;
padding: 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
@@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
- border-radius: 6px;
+ border-radius: 8px;
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;
-}}
-
+/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
+QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
- border: 2px solid {c['border_focus']};
- padding: 5px 27px 5px 7px;
+ border: 1px solid {c['border_focus']};
}}
/* 비활성 입력 필드 */
@@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
QSpinBox::up-button, QDoubleSpinBox::up-button,
QDateEdit::up-button, QTimeEdit::up-button {{
subcontrol-position: top right;
- border-top-right-radius: 4px;
+ border-top-right-radius: 7px;
}}
QSpinBox::down-button, QDoubleSpinBox::down-button,
QDateEdit::down-button, QTimeEdit::down-button {{
subcontrol-position: bottom right;
- border-bottom-right-radius: 4px;
+ border-bottom-right-radius: 7px;
}}
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
@@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
QProgressBar {{
border: none;
background: {c['progress_bg']};
- border-radius: 4px;
- height: 8px;
+ border-radius: 3px;
+ min-height: 6px;
+ max-height: 6px;
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;
+ background: {c['progress_start']};
+ border-radius: 3px;
}}
/* ════════════════════════════════════════
@@ -648,7 +694,7 @@ QProgressBar::chunk {{
QTableWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
- border-radius: 6px;
+ border-radius: 8px;
gridline-color: {c['border_subtle']};
color: {c['text_primary']};
font-size: 9pt;
@@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
background: {c['bg_tertiary']};
}}
+/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
+QHeaderView {{
+ background: {c['bg_secondary']};
+ border: none;
+}}
+
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;
}}
+QHeaderView::section:horizontal {{
+ border-bottom: 2px solid {c['accent_primary']};
+}}
+
+/* 세로헤더(행번호) — accent 밑줄 없이 미묘하게 */
+QHeaderView::section:vertical {{
+ border-right: 1px solid {c['border_subtle']};
+ color: {c['text_tertiary']};
+ font-weight: normal;
+ padding: 4px 8px;
+}}
+
+/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
+QTableView QTableCornerButton::section {{
+ background: {c['bg_tertiary']};
+ border: none;
+ border-bottom: 2px solid {c['accent_primary']};
+}}
+
/* ════════════════════════════════════════
탭 위젯
════════════════════════════════════════ */
QTabWidget::pane {{
border: 1px solid {c['border_subtle']};
- border-radius: 6px;
+ border-radius: 8px;
background: {c['bg_secondary']};
top: -1px;
}}
@@ -694,8 +764,8 @@ QTabBar::tab {{
padding: 8px 20px;
border: 1px solid {c['border_subtle']};
border-bottom: none;
- border-top-left-radius: 6px;
- border-top-right-radius: 6px;
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
margin-right: 2px;
font-size: 10pt;
}}
@@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
QCalendarWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
- border-radius: 6px;
+ border-radius: 8px;
font-size: 10pt;
}}
@@ -902,7 +972,7 @@ QToolTip {{
QMenu {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
- border-radius: 6px;
+ border-radius: 8px;
padding: 4px;
color: {c['text_primary']};
}}
@@ -916,6 +986,16 @@ QMenu::item:selected {{
background: {c['accent_primary']};
color: {c['text_inverse']};
}}
+
+QMenu::separator {{
+ height: 1px;
+ background: {c['border_subtle']};
+ margin: 4px 8px;
+}}
+
+QMenu::icon {{
+ padding-left: 8px;
+}}
"""
diff --git a/ui/today_summary.py b/ui/today_summary.py
index a6af2c7..3707dbf 100644
--- a/ui/today_summary.py
+++ b/ui/today_summary.py
@@ -16,12 +16,12 @@ class TodaySummaryCard(QFrame):
self.setObjectName("today_summary_card")
self.setStyleSheet("""
QFrame#today_summary_card {
- background-color: rgba(76, 175, 80, 0.08);
- border: 1px solid rgba(76, 175, 80, 0.4);
+ background-color: rgba(81, 207, 102, 0.08);
+ border: 1px solid rgba(81, 207, 102, 0.40);
border-radius: 8px;
padding: 6px;
}
- QLabel { padding: 1px; }
+ QLabel { padding: 1px; background: transparent; border: none; }
""")
self.setVisible(False)
@@ -30,7 +30,7 @@ class TodaySummaryCard(QFrame):
layout.setSpacing(2)
header = QHBoxLayout()
- title = QLabel("📋 오늘의 요약")
+ title = QLabel("오늘의 요약")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
@@ -43,9 +43,9 @@ class TodaySummaryCard(QFrame):
self.total_label = QLabel("")
self.detail_label = QLabel("")
- self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
+ self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
self.salary_label = QLabel("")
- self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
+ self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
layout.addWidget(self.total_label)
layout.addWidget(self.detail_label)
@@ -70,7 +70,7 @@ class TodaySummaryCard(QFrame):
"""
h = int(total_hours)
m = int((total_hours - h) * 60)
- self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
+ self.total_label.setText(f"총 근무: {h}시간 {m}분")
details = []
if lunch_minutes > 0:
@@ -85,7 +85,7 @@ class TodaySummaryCard(QFrame):
self.detail_label.setVisible(bool(details))
if salary_text:
- self.salary_label.setText(f"💰 {salary_text}")
+ self.salary_label.setText(f"{salary_text}")
self.salary_label.setVisible(True)
else:
self.salary_label.setVisible(False)
diff --git a/utils/font_loader.py b/utils/font_loader.py
new file mode 100644
index 0000000..bc17feb
--- /dev/null
+++ b/utils/font_loader.py
@@ -0,0 +1,84 @@
+"""번들 폰트(NanumSquare) 로딩.
+
+`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
+PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
+등록 실패 시 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
+"""
+from __future__ import annotations
+import os
+import sys
+
+from PyQt5.QtGui import QFontDatabase, QFont
+
+# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
+# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
+_FONT_FILES = [
+ 'NanumSquareL.ttf',
+ 'NanumSquareR.ttf',
+ 'NanumSquareB.ttf',
+ 'NanumSquareEB.ttf',
+ 'NanumSquare_acR.ttf',
+ 'NanumSquare_acB.ttf',
+]
+
+
+def _font_dir() -> str:
+ """번들 font/ 디렉토리 절대 경로."""
+ if getattr(sys, 'frozen', False):
+ base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
+ else:
+ base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ return os.path.join(base, 'font')
+
+
+def load_bundled_fonts() -> list:
+ """번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
+ families: list = []
+ fdir = _font_dir()
+ if not os.path.isdir(fdir):
+ return families
+ for name in _FONT_FILES:
+ path = os.path.join(fdir, name)
+ if not os.path.exists(path):
+ continue
+ fid = QFontDatabase.addApplicationFont(path)
+ if fid == -1:
+ continue
+ for fam in QFontDatabase.applicationFontFamilies(fid):
+ if fam not in families:
+ families.append(fam)
+ return families
+
+
+def _pick_primary(families: list) -> str:
+ """등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
+ if 'NanumSquare' in families:
+ return 'NanumSquare'
+ for fam in families:
+ low = fam.lower()
+ if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
+ return fam
+ return 'Malgun Gothic'
+
+
+def apply_app_font(app, point_size: int = 9) -> str:
+ """앱 전역 기본 폰트를 NanumSquare로 설정.
+
+ Returns:
+ 실제 적용된 primary family 이름 (폴백 시 'Malgun Gothic').
+ """
+ families = load_bundled_fonts()
+ primary = _pick_primary(families)
+ font = QFont(primary, point_size)
+ font.setStyleStrategy(QFont.PreferAntialias)
+ app.setFont(font)
+ return primary
+
+
+if __name__ == '__main__':
+ from PyQt5.QtWidgets import QApplication
+ _app = QApplication(sys.argv)
+ fams = load_bundled_fonts()
+ print('font dir:', _font_dir())
+ print('registered families:', fams)
+ print('picked primary:', _pick_primary(fams))
diff --git a/utils/system_tray.py b/utils/system_tray.py
index 64362d6..af67cb1 100644
--- a/utils/system_tray.py
+++ b/utils/system_tray.py
@@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
return QIcon(pixmap)
def setup_menu(self):
- """트레이 메뉴 설정"""
+ """트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
menu = QMenu()
- show_action = QAction(tr('tray.open'), self)
- show_action.triggered.connect(self.show_window)
- menu.addAction(show_action)
+ # (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
+ self._icon_actions = []
- mini_action = QAction(tr('tray.mini_widget'), self)
- mini_action.triggered.connect(self._open_mini_widget)
- menu.addAction(mini_action)
+ def add(text, slot, icon_name=None):
+ action = QAction(text, self)
+ action.triggered.connect(slot)
+ menu.addAction(action)
+ if icon_name:
+ self._icon_actions.append((action, icon_name))
+ return action
+
+ add(tr('tray.open'), self.show_window, 'home')
+ add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
menu.addSeparator()
- lunch_action = QAction(tr('tray.toggle_lunch'), self)
- lunch_action.triggered.connect(self._toggle_lunch)
- menu.addAction(lunch_action)
-
- break_out_action = QAction(tr('btn.break_out'), self)
- break_out_action.triggered.connect(self._break_out)
- menu.addAction(break_out_action)
-
- break_in_action = QAction(tr('btn.break_in'), self)
- break_in_action.triggered.connect(self._break_in)
- menu.addAction(break_in_action)
+ add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
+ add(tr('btn.break_out'), self._break_out)
+ add(tr('btn.break_in'), self._break_in)
menu.addSeparator()
- clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
- clock_out_action.triggered.connect(self.quick_clock_out)
- menu.addAction(clock_out_action)
+ add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
menu.addSeparator()
- stats_action = QAction("📊 " + tr('menu.stats'), self)
- stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
- menu.addAction(stats_action)
-
- cal_action = QAction("📅 " + tr('menu.calendar'), self)
- cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
- menu.addAction(cal_action)
-
- schedule_action = QAction("🗓️ 스케줄", self)
- schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
- menu.addAction(schedule_action)
-
- help_action = QAction("📖 " + tr('menu.help'), self)
- help_action.triggered.connect(lambda: self._call_parent('show_help'))
- menu.addAction(help_action)
+ add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
+ add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
+ add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
+ add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
menu.addSeparator()
- quit_action = QAction(tr('tray.quit'), self)
- quit_action.triggered.connect(self.quit_app)
- menu.addAction(quit_action)
+ add(tr('tray.quit'), self.quit_app)
self.setContextMenu(menu)
+ self.refresh_theme()
+
+ def refresh_theme(self):
+ """트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
+
+ QMenu()는 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
+ 명시적으로 적용한다. 테마 변경 시 main_window.apply_theme에서 호출.
+ """
+ menu = self.contextMenu()
+ if menu is None:
+ return
+ # 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
+ qss = self.parent_window.styleSheet() if self.parent_window else ''
+ if not qss:
+ try:
+ from ui.styles import get_theme
+ qss = get_theme('dark')
+ except Exception:
+ qss = ''
+ if qss:
+ menu.setStyleSheet(qss)
+ # 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
+ try:
+ from ui.icons import get_icon
+ from ui.styles import ThemeColors
+ color = ThemeColors.get('text_primary')
+ for action, name in getattr(self, '_icon_actions', []):
+ action.setIcon(get_icon(name, color, 16))
+ except Exception:
+ pass
def _call_parent(self, method_name: str):
if self.parent_window and hasattr(self.parent_window, method_name):