diff --git a/.jules/bolt.md b/.jules/bolt.md index cd7b67b..a01aae6 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -11,3 +11,6 @@ **Инсайт:** Простое перечисление заблокированных достижений со статусом "заперто" демотивирует. Отображение прогресс-бара и числовых значений (7/10) внутри списка достижений значительно повышает вовлеченность пользователя. **Действие:** В StatsDialog для каждого заблокированного достижения рассчитывать процент выполнения и отображать QProgressBar. +## 2026-07-06 - [Interactive Countdown and Live Status Tooltips] +**Инсайт:** Скрытые фоновые процессы (например, таймеры Pomodoro) без обратной связи снижают удобство использования (UX), так как пользователь не знает, активен ли процесс и сколько времени осталось. Обеспечение обратной связи через живые всплывающие подсказки (tooltips) как на трее, так и на главном окне питомца с частотой обновления 1-2 Гц решает эту проблему без ущерба для CPU. +**Действие:** Запускать таймеры Pomodoro с интервалом в 1 секунду для вычисления оставшегося времени в реальном времени, передавать прогресс по Qt сигналам, и обновлять тултипы в watchdog-цикле ввода с частотой 2 Гц. diff --git a/src/core/input_manager.py b/src/core/input_manager.py index ac23bb7..56a7463 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -155,6 +155,9 @@ 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. Обновление интерактивной всплывающей подсказки + 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..26cb114 100644 --- a/src/core/timer_system.py +++ b/src/core/timer_system.py @@ -2,7 +2,8 @@ class TimerSystem(QObject): stretch_reminder = Signal() - pomodoro_finished = Signal(str) # 'work' or 'break' + pomodoro_finished = Signal(str) # 'work' or 'break' + pomodoro_tick = Signal(str, int) # state, remaining_seconds def __init__(self, config): super().__init__() @@ -15,10 +16,12 @@ def __init__(self, config): # Таймер Pomodoro self.pomodoro_timer = QTimer(self) self.pomodoro_timer.timeout.connect(self.on_pomodoro_timeout) - self.pomodoro_state = "idle" # 'work', 'break', 'idle' + self.pomodoro_state = "idle" # 'work', 'break', 'idle' + self.pomodoro_duration = 0 + self.pomodoro_remaining = 0 def start_stretch_timer(self): - interval = self.config.get("stretch_interval") * 60 * 1000 # в мс + interval = self.config.get("stretch_interval") * 60 * 1000 # в мс self.stretch_timer.start(interval) def restart_stretch_timer(self): @@ -31,10 +34,23 @@ 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_duration = minutes * 60 + self.pomodoro_remaining = self.pomodoro_duration + self.pomodoro_timer.start(1000) # Срабатывает каждую секунду + self.pomodoro_tick.emit(self.pomodoro_state, self.pomodoro_remaining) - def on_pomodoro_timeout(self): - last_state = self.pomodoro_state + def stop_pomodoro(self): self.pomodoro_timer.stop() self.pomodoro_state = "idle" - self.pomodoro_finished.emit(last_state) + self.pomodoro_duration = 0 + self.pomodoro_remaining = 0 + self.pomodoro_tick.emit("idle", 0) + + def on_pomodoro_timeout(self): + if self.pomodoro_remaining > 1: + self.pomodoro_remaining -= 1 + self.pomodoro_tick.emit(self.pomodoro_state, self.pomodoro_remaining) + else: + last_state = self.pomodoro_state + self.stop_pomodoro() + self.pomodoro_finished.emit(last_state) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index a2c4b6f..ee499a7 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -4,6 +4,7 @@ from PySide6.QtCore import Qt, QPoint, QSize, Signal, QPropertyAnimation, QEasingCurve, QTimer from src.core.animation_manager import AnimationManager from src.utils.sound_manager import SoundManager +from src.utils.bonding_utils import get_level_info class PetWindow(QMainWindow): closed = Signal() @@ -77,6 +78,46 @@ def __init__(self, config_manager=None): self.message_hide_timer.setSingleShot(True) self.message_hide_timer.timeout.connect(self.message_label.hide) + self.update_tooltip() + + def update_tooltip(self): + """Обновляет всплывающую подсказку при наведении мыши на котика.""" + points = 0 + level = 0 + title = "Знакомый" + points_in_level = 0 + points_for_next_level = 0 + + if self.input_manager: + points = self.input_manager.last_affection_points + self.input_manager.pending_points + level, title, points_in_level, points_for_next_level = get_level_info(points) + + tooltip_lines = [ + "🐾 Котик-помощник 🐾", + f"Уровень {level}: {title}", + ] + + if points_for_next_level > 0: + tooltip_lines.append(f"Привязанность: {points_in_level}/{points_for_next_level} ❤️ (Всего: {points})") + else: + tooltip_lines.append(f"Привязанность: {points} ❤️ (Макс. уровень! 🎉)") + + if self.input_manager: + tooltip_lines.append(f"Рекорд KPS: {self.input_manager.max_kps} кл/сек ⚡") + + # Если запущен Pomodoro, добавляем статус + if self.timer_system and self.timer_system.pomodoro_state != "idle": + state = self.timer_system.pomodoro_state + remaining = self.timer_system.pomodoro_remaining + mins = remaining // 60 + secs = remaining % 60 + mode_name = "Работа 🛠" if state == "work" else "Отдых ☕" + tooltip_lines.append(f"⏱️ {mode_name}: {mins:02d}:{secs:02d}") + + tooltip_text = "\n".join(tooltip_lines) + self.setToolTip(tooltip_text) + self.pet_label.setToolTip(tooltip_text) + def moveEvent(self, event): self._cached_pos = event.pos() super().moveEvent(event) diff --git a/src/ui/tray_menu.py b/src/ui/tray_menu.py index 9ddc7c8..2266a1a 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -13,6 +13,8 @@ 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 +27,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.on_pomodoro_tick) + self.window.timer_system.pomodoro_finished.connect(self.on_pomodoro_finished) + def setup_menu(self): # Действия с питомцем feed_action = QAction("Покормить", self) @@ -60,16 +66,31 @@ def setup_menu(self): self.menu.addSeparator() # Pomodoro - pomodoro_menu = QMenu("Таймер Pomodoro", self.menu) - start_work = QAction("Начать работу (25 мин)", self) - start_work.triggered.connect(self.start_work_timer) - pomodoro_menu.addAction(start_work) + self.pomodoro_menu = QMenu("Таймер Pomodoro", self.menu) + + self.pomodoro_status_action = QAction("Активного таймера нет", self) + self.pomodoro_status_action.setEnabled(False) + self.pomodoro_menu.addAction(self.pomodoro_status_action) + + self.pomodoro_menu.addSeparator() - start_break = QAction("Перерыв (5 мин)", self) - start_break.triggered.connect(self.start_break_timer) - pomodoro_menu.addAction(start_break) + self.start_work_action = QAction("Начать работу", self) + self.start_work_action.triggered.connect(self.start_work_timer) + self.pomodoro_menu.addAction(self.start_work_action) - self.menu.addMenu(pomodoro_menu) + self.start_break_action = QAction("Перерыв", self) + self.start_break_action.triggered.connect(self.start_break_timer) + self.pomodoro_menu.addAction(self.start_break_action) + + self.stop_pomodoro_action = QAction("Остановить таймер", self) + self.stop_pomodoro_action.triggered.connect(self.stop_pomodoro_timer) + self.stop_pomodoro_action.setVisible(False) + self.pomodoro_menu.addAction(self.stop_pomodoro_action) + + self.menu.addMenu(self.pomodoro_menu) + + # Обновляем тексты в меню на основе конфигурации + self.update_pomodoro_menu_texts() self.menu.addSeparator() @@ -97,6 +118,12 @@ def setup_menu(self): quit_action.triggered.connect(self.quit_app) self.menu.addAction(quit_action) + def update_pomodoro_menu_texts(self): + work_mins = self.window.config.get("pomodoro_work") + break_mins = self.window.config.get("pomodoro_break") + self.start_work_action.setText(f"Начать работу ({work_mins} мин)") + self.start_break_action.setText(f"Перерыв ({break_mins} мин)") + def quit_app(self, checked=False): self.window.close() QApplication.instance().quit() @@ -118,12 +145,50 @@ def start_break_timer(self, checked=False): if self.window.input_manager and self.window.input_manager.db: self.window.input_manager.db.log_event("pomodoro_start", "Начата сессия отдыха") + def stop_pomodoro_timer(self, checked=False): + if self.window.timer_system: + self.window.timer_system.stop_pomodoro() + self.window.show_message("Таймер остановлен ⏹️") + if self.window.input_manager and self.window.input_manager.db: + self.window.input_manager.db.log_event("pomodoro_stop", "Таймер Pomodoro остановлен пользователем") + + def on_pomodoro_tick(self, mode, remaining_seconds): + if mode == "idle" or remaining_seconds <= 0: + self.pomodoro_status_action.setText("Активного таймера нет") + self.start_work_action.setVisible(True) + self.start_break_action.setVisible(True) + self.stop_pomodoro_action.setVisible(False) + self.tray_icon.setToolTip("Котик-помощник 🐾") + else: + mins = remaining_seconds // 60 + secs = remaining_seconds % 60 + time_str = f"{mins:02d}:{secs:02d}" + + mode_name = "Работа" if mode == "work" else "Отдых" + mode_icon = "🛠" if mode == "work" else "☕" + + self.pomodoro_status_action.setText(f"Идет сессия: {mode_name} ({time_str})") + self.start_work_action.setVisible(False) + self.start_break_action.setVisible(False) + self.stop_pomodoro_action.setVisible(True) + + self.tray_icon.setToolTip(f"Котик работает {mode_icon} (Осталось: {time_str})" if mode == "work" else f"Котик отдыхает {mode_icon} (Осталось: {time_str})") + + # Обновляем тултип главного окна, если применимо + if hasattr(self.window, 'update_tooltip'): + self.window.update_tooltip() + + def on_pomodoro_finished(self, mode): + self.on_pomodoro_tick("idle", 0) + def show_settings(self, checked=False): dialog = SettingsDialog(self.window.config, self.window) if dialog.exec(): # Обновляем скин и прозрачность в реальном времени self.window.animation_manager.set_skin(self.window.config.get("skin")) self.window.set_opacity(self.window.config.get("opacity")) + # Обновляем тексты меню Pomodoro + self.update_pomodoro_menu_texts() # Перезапускаем таймер растяжки с новым интервалом if self.window.timer_system: self.window.timer_system.restart_stretch_timer() diff --git a/tests/test_timer_system.py b/tests/test_timer_system.py new file mode 100644 index 0000000..0583dc6 --- /dev/null +++ b/tests/test_timer_system.py @@ -0,0 +1,93 @@ +import unittest +from PySide6.QtWidgets import QApplication +from src.core.timer_system import TimerSystem +from src.utils.config_manager import ConfigManager + +# Ensure there's a QApplication instance for QTimer / QObject creation +app = QApplication.instance() or QApplication([]) + +class TestTimerSystem(unittest.TestCase): + def setUp(self): + self.config = ConfigManager("test_settings.json") + self.config.set("pomodoro_work", 25) + self.config.set("pomodoro_break", 5) + self.timer_system = TimerSystem(self.config) + + def test_initial_state(self): + self.assertEqual(self.timer_system.pomodoro_state, "idle") + self.assertEqual(self.timer_system.pomodoro_duration, 0) + self.assertEqual(self.timer_system.pomodoro_remaining, 0) + + def test_start_pomodoro(self): + tick_emitted = [] + def on_tick(state, remaining): + tick_emitted.append((state, remaining)) + + self.timer_system.pomodoro_tick.connect(on_tick) + self.timer_system.start_pomodoro("work") + + self.assertEqual(self.timer_system.pomodoro_state, "work") + self.assertEqual(self.timer_system.pomodoro_duration, 25 * 60) + self.assertEqual(self.timer_system.pomodoro_remaining, 25 * 60) + self.assertTrue(self.timer_system.pomodoro_timer.isActive()) + + # Verify initial tick was emitted immediately + self.assertEqual(len(tick_emitted), 1) + self.assertEqual(tick_emitted[0], ("work", 25 * 60)) + + def test_pomodoro_countdown_ticks(self): + tick_emitted = [] + def on_tick(state, remaining): + tick_emitted.append((state, remaining)) + + self.timer_system.pomodoro_tick.connect(on_tick) + self.timer_system.start_pomodoro("work") + + # Simulate a countdown tick + self.timer_system.on_pomodoro_timeout() + self.assertEqual(self.timer_system.pomodoro_remaining, 25 * 60 - 1) + self.assertEqual(len(tick_emitted), 2) + self.assertEqual(tick_emitted[1], ("work", 25 * 60 - 1)) + + def test_stop_pomodoro(self): + tick_emitted = [] + def on_tick(state, remaining): + tick_emitted.append((state, remaining)) + + self.timer_system.pomodoro_tick.connect(on_tick) + 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_duration, 0) + self.assertEqual(self.timer_system.pomodoro_remaining, 0) + self.assertFalse(self.timer_system.pomodoro_timer.isActive()) + + # Two ticks should have been emitted: start and stop + self.assertEqual(len(tick_emitted), 2) + self.assertEqual(tick_emitted[-1], ("idle", 0)) + + def test_pomodoro_finished(self): + finished_emitted = [] + def on_finished(mode): + finished_emitted.append(mode) + + self.timer_system.pomodoro_finished.connect(on_finished) + self.timer_system.start_pomodoro("break") + + # Set remaining to 1 second so next timeout triggers finished + self.timer_system.pomodoro_remaining = 1 + self.timer_system.on_pomodoro_timeout() + + self.assertEqual(self.timer_system.pomodoro_state, "idle") + self.assertEqual(len(finished_emitted), 1) + self.assertEqual(finished_emitted[0], "break") + self.assertFalse(self.timer_system.pomodoro_timer.isActive()) + + def tearDown(self): + import os + if os.path.exists("test_settings.json"): + os.remove("test_settings.json") + +if __name__ == "__main__": + unittest.main() diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 341bd7d..5a80211 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ