Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 Гц.
3 changes: 3 additions & 0 deletions src/core/input_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 23 additions & 7 deletions src/core/timer_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand All @@ -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):
Expand All @@ -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)
41 changes: 41 additions & 0 deletions src/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
81 changes: 73 additions & 8 deletions src/ui/tray_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
93 changes: 93 additions & 0 deletions tests/test_timer_system.py
Original file line number Diff line number Diff line change
@@ -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()
Binary file modified verification/screenshots/stats_history.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.