diff --git a/.jules/bolt.md b/.jules/bolt.md index cd7b67b..750340e 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -11,3 +11,6 @@ **Инсайт:** Простое перечисление заблокированных достижений со статусом "заперто" демотивирует. Отображение прогресс-бара и числовых значений (7/10) внутри списка достижений значительно повышает вовлеченность пользователя. **Действие:** В StatsDialog для каждого заблокированного достижения рассчитывать процент выполнения и отображать QProgressBar. +## 2026-07-06 - [Real-Time Pomodoro countdown with Dynamic Tooltips] +**Инсайт:** Стандартные таймеры без визуального таймера обратного отсчета снижают пользовательский контроль и ухудшают UX. Обновление обратного отсчета по секундам (1 Гц) практически не нагружает процессор, но значительно повышает удобство использования таймеров Pomodoro. +**Действие:** Переработать TimerSystem на посекундный шаг с сигналом `pomodoro_tick` и обновлять тултипы трея и основного окна динамически. diff --git a/src/core/input_manager.py b/src/core/input_manager.py index ac23bb7..fc1154d 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -155,6 +155,10 @@ def periodic_check(self): self.window.animation_manager.play_state("hunting") self.window.start_hunting(self.last_mouse_pos[0], self.last_mouse_pos[1]) + # 4. Обновление тултипа окна питомца + if hasattr(self.window, "update_tooltip"): + self.window.update_tooltip() + def add_points(self, points): """Добавляет очки и проверяет повышение уровня.""" if not self.db: diff --git a/src/core/timer_system.py b/src/core/timer_system.py index ff27dec..5ec9398 100644 --- a/src/core/timer_system.py +++ b/src/core/timer_system.py @@ -3,6 +3,7 @@ class TimerSystem(QObject): stretch_reminder = Signal() pomodoro_finished = Signal(str) # 'work' or 'break' + pomodoro_tick = Signal(int) # remaining seconds def __init__(self, config): super().__init__() @@ -12,10 +13,11 @@ def __init__(self, config): self.stretch_timer = QTimer(self) self.stretch_timer.timeout.connect(self.on_stretch_timeout) - # Таймер Pomodoro + # Таймер Pomodoro (работает с секундным интервалом) self.pomodoro_timer = QTimer(self) - self.pomodoro_timer.timeout.connect(self.on_pomodoro_timeout) + self.pomodoro_timer.timeout.connect(self.on_pomodoro_tick) self.pomodoro_state = "idle" # 'work', 'break', 'idle' + self.pomodoro_remaining = 0 def start_stretch_timer(self): interval = self.config.get("stretch_interval") * 60 * 1000 # в мс @@ -31,10 +33,27 @@ def on_stretch_timeout(self): def start_pomodoro(self, mode="work"): self.pomodoro_state = mode minutes = self.config.get(f"pomodoro_{mode}") - self.pomodoro_timer.start(minutes * 60 * 1000) + self.pomodoro_remaining = minutes * 60 + self.pomodoro_timer.start(1000) # Секундный интервал + self.pomodoro_tick.emit(self.pomodoro_remaining) + + def on_pomodoro_tick(self): + if self.pomodoro_remaining > 0: + self.pomodoro_remaining -= 1 + self.pomodoro_tick.emit(self.pomodoro_remaining) + if self.pomodoro_remaining <= 0: + self.on_pomodoro_timeout() def on_pomodoro_timeout(self): last_state = self.pomodoro_state self.pomodoro_timer.stop() self.pomodoro_state = "idle" + self.pomodoro_remaining = 0 self.pomodoro_finished.emit(last_state) + + def stop_pomodoro(self): + if self.pomodoro_state != "idle": + self.pomodoro_timer.stop() + self.pomodoro_state = "idle" + self.pomodoro_remaining = 0 + self.pomodoro_tick.emit(0) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index a2c4b6f..908a51e 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -223,6 +223,39 @@ def mouseReleaseEvent(self, event): self.animation_manager.update_size(self.size()) self.animation_manager.play_state(self.last_state_before_drag) + def update_tooltip(self): + """Обновляет интерактивный тултип окна питомца""" + if not self.input_manager: + return + + # Получаем данные из InputManager и Config + name = self.config.get("username", "Котик") if self.config else "Котик" + points = self.input_manager.last_affection_points + self.input_manager.pending_points + + # Получаем уровень и титул + from src.utils.bonding_utils import get_level_info + level, title, _, _ = get_level_info(points) + + max_kps = self.input_manager.max_kps + + # Статус таймера Pomodoro + pomodoro_text = "Не активен" + if self.timer_system and self.timer_system.pomodoro_state != "idle": + state_name = "Работа" if self.timer_system.pomodoro_state == "work" else "Перерыв" + rem = self.timer_system.pomodoro_remaining + mins = rem // 60 + secs = rem % 60 + pomodoro_text = f"{state_name} ({mins:02d}:{secs:02d})" + + tooltip_content = ( + f"Имя: {name}\n" + f"Уровень {level}: {title}\n" + f"Привязанность: {points} ❤️\n" + f"Рекорд: {max_kps} кл/сек ⚡\n" + f"Таймер Pomodoro: {pomodoro_text}" + ) + self.setToolTip(tooltip_content) + def closeEvent(self, event): self.closed.emit() super().closeEvent(event) diff --git a/src/ui/tray_menu.py b/src/ui/tray_menu.py index 9ddc7c8..71b42ee 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -13,6 +13,7 @@ def __init__(self, pet_window): self.window = pet_window self.tray_icon = QSystemTrayIcon(self.window) + self.tray_icon.setToolTip("Десктопный Котик 🐾") # Используем статичную PNG иконку для трея if os.path.exists(TRAY_ICON_PATH): self.tray_icon.setIcon(QIcon(TRAY_ICON_PATH)) @@ -25,6 +26,10 @@ def __init__(self, pet_window): self.tray_icon.setContextMenu(self.menu) self.tray_icon.show() + if self.window.timer_system: + self.window.timer_system.pomodoro_tick.connect(self.update_pomodoro_status) + self.window.timer_system.pomodoro_finished.connect(self.on_pomodoro_finished) + def setup_menu(self): # Действия с питомцем feed_action = QAction("Покормить", self) @@ -61,6 +66,8 @@ def setup_menu(self): # Pomodoro pomodoro_menu = QMenu("Таймер Pomodoro", self.menu) + self.pomodoro_menu = pomodoro_menu + start_work = QAction("Начать работу (25 мин)", self) start_work.triggered.connect(self.start_work_timer) pomodoro_menu.addAction(start_work) @@ -69,6 +76,15 @@ def setup_menu(self): start_break.triggered.connect(self.start_break_timer) pomodoro_menu.addAction(start_break) + self.pomodoro_status_action = QAction("Статус: Не активен", self) + self.pomodoro_status_action.setEnabled(False) + pomodoro_menu.addAction(self.pomodoro_status_action) + + self.pomodoro_stop_action = QAction("Остановить таймер", self) + self.pomodoro_stop_action.triggered.connect(self.stop_pomodoro_timer) + self.pomodoro_stop_action.setVisible(False) + pomodoro_menu.addAction(self.pomodoro_stop_action) + self.menu.addMenu(pomodoro_menu) self.menu.addSeparator() @@ -136,6 +152,35 @@ def show_stats(self, checked=False): dialog = StatsDialog(self.window.input_manager.db, self.window) dialog.exec() + def stop_pomodoro_timer(self, checked=False): + if self.window.timer_system: + self.window.timer_system.stop_pomodoro() + self.window.show_message("Таймер остановлен ⏱️") + + def update_pomodoro_status(self, remaining_seconds): + if not self.window.timer_system: + return + + state = self.window.timer_system.pomodoro_state + if state == "idle" or remaining_seconds <= 0: + self.pomodoro_status_action.setText("Статус: Не активен") + self.pomodoro_stop_action.setVisible(False) + self.tray_icon.setToolTip("Десктопный Котик 🐾") + else: + mins = remaining_seconds // 60 + secs = remaining_seconds % 60 + state_str = "Работа" if state == "work" else "Перерыв" + time_str = f"{mins:02d}:{secs:02d}" + + self.pomodoro_status_action.setText(f"Осталось ({state_str}): {time_str}") + self.pomodoro_stop_action.setVisible(True) + self.tray_icon.setToolTip(f"Десктопный Котик 🐾\n({state_str}: {time_str})") + + def on_pomodoro_finished(self, mode): + self.pomodoro_status_action.setText("Статус: Не активен") + self.pomodoro_stop_action.setVisible(False) + self.tray_icon.setToolTip("Десктопный Котик 🐾") + def feed_pet(self, checked=False): self.window.animation_manager.play_state("eating") if self.window.input_manager: diff --git a/tests/test_timer_system.py b/tests/test_timer_system.py new file mode 100644 index 0000000..bfe5e2b --- /dev/null +++ b/tests/test_timer_system.py @@ -0,0 +1,82 @@ +import sys +import unittest +from PySide6.QtWidgets import QApplication +from src.core.timer_system import TimerSystem +from src.utils.config_manager import ConfigManager + +# Ensure QApplication instance is created for QTimer +app = QApplication.instance() or QApplication(sys.argv) + +class TestTimerSystem(unittest.TestCase): + def setUp(self): + # Create a mock config or ConfigManager + self.config = ConfigManager("test_timer_settings.json") + # Ensure our test settings are set + self.config.set("pomodoro_work", 25) + self.config.set("pomodoro_break", 5) + self.timer_system = TimerSystem(self.config) + + def tearDown(self): + # Stop any active timers + self.timer_system.stretch_timer.stop() + self.timer_system.pomodoro_timer.stop() + import os + if os.path.exists("test_timer_settings.json"): + os.remove("test_timer_settings.json") + + def test_initial_state(self): + self.assertEqual(self.timer_system.pomodoro_state, "idle") + self.assertEqual(self.timer_system.pomodoro_remaining, 0) + + def test_start_pomodoro_work(self): + self.timer_system.start_pomodoro("work") + self.assertEqual(self.timer_system.pomodoro_state, "work") + self.assertEqual(self.timer_system.pomodoro_remaining, 25 * 60) + self.assertTrue(self.timer_system.pomodoro_timer.isActive()) + + def test_start_pomodoro_break(self): + self.timer_system.start_pomodoro("break") + self.assertEqual(self.timer_system.pomodoro_state, "break") + self.assertEqual(self.timer_system.pomodoro_remaining, 5 * 60) + self.assertTrue(self.timer_system.pomodoro_timer.isActive()) + + def test_stop_pomodoro(self): + self.timer_system.start_pomodoro("work") + self.timer_system.stop_pomodoro() + self.assertEqual(self.timer_system.pomodoro_state, "idle") + self.assertEqual(self.timer_system.pomodoro_remaining, 0) + self.assertFalse(self.timer_system.pomodoro_timer.isActive()) + + def test_pomodoro_tick(self): + ticks = [] + self.timer_system.pomodoro_tick.connect(lambda val: ticks.append(val)) + + self.timer_system.start_pomodoro("work") + # Remaining starts at 25 * 60 + self.assertEqual(self.timer_system.pomodoro_remaining, 1500) + self.assertEqual(len(ticks), 1) + self.assertEqual(ticks[0], 1500) + + # Simulate a tick + self.timer_system.on_pomodoro_tick() + self.assertEqual(self.timer_system.pomodoro_remaining, 1499) + self.assertEqual(len(ticks), 2) + self.assertEqual(ticks[1], 1499) + + def test_pomodoro_finish(self): + finished_modes = [] + self.timer_system.pomodoro_finished.connect(lambda mode: finished_modes.append(mode)) + + self.timer_system.start_pomodoro("break") + self.timer_system.pomodoro_remaining = 1 # Set remaining to 1 second + + # Simulate final tick to 0 + self.timer_system.on_pomodoro_tick() + self.assertEqual(self.timer_system.pomodoro_remaining, 0) + self.assertEqual(self.timer_system.pomodoro_state, "idle") + self.assertEqual(len(finished_modes), 1) + self.assertEqual(finished_modes[0], "break") + self.assertFalse(self.timer_system.pomodoro_timer.isActive()) + +if __name__ == "__main__": + unittest.main() diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 341bd7d..d7351a2 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ