diff --git a/src/core/input_manager.py b/src/core/input_manager.py index 9ca9d10..e1884ec 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -44,6 +44,8 @@ def stop(self): self.keyboard_listener.stop() class InputManager(QObject): + laser_mode_changed = Signal(bool) + def __init__(self, pet_window, data_store=None): super().__init__() self.window = pet_window @@ -410,8 +412,17 @@ def handle_mouse(self, x, y): dy_pet = y - center_y dist_sq_pet = dx_pet * dx_pet + dy_pet * dy_pet - # Оптимизация: сравнение квадрата расстояния (порог 60px -> 3600) - if dist_sq_pet < 3600: + # Динамический радиус поглаживания в зависимости от pet_size (порог по умолчанию 60px -> 3600 при размерности 100) + pet_size = 100 + if self.window and hasattr(self.window, "config") and self.window.config: + val = self.window.config.get("pet_size") + if isinstance(val, (int, float)): + pet_size = val + + pet_radius = int(60 * (pet_size / 100.0)) + pet_radius_sq = pet_radius * pet_radius + + if dist_sq_pet < pet_radius_sq: # Исключаем пассивный фарм (требуем активное поглаживание: активное движение мыши и кулдаун) if self.last_pet_time == 0: self.last_pet_mouse_pos = (x, y) @@ -440,8 +451,12 @@ def handle_mouse(self, x, y): # Сброс начальной точки поглаживания при выходе за пределы питомца self.last_pet_time = 0 - def toggle_laser_mode(self): - self.laser_mode = not self.laser_mode + def toggle_laser_mode(self, enabled=None): + if enabled is not None: + self.laser_mode = bool(enabled) + else: + self.laser_mode = not self.laser_mode + if self.laser_mode: self.window.animation_manager.play_state("hunting") # Создаем красивый светящийся красный лазерный курсор @@ -470,6 +485,8 @@ def toggle_laser_mode(self): else: self.window.setCursor(Qt.ArrowCursor) self.window.animation_manager.play_state("idle") + + self.laser_mode_changed.emit(self.laser_mode) return self.laser_mode def reset_all_data(self): diff --git a/src/ui/main_window.py b/src/ui/main_window.py index e619905..a080018 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -55,7 +55,8 @@ def __init__(self, config_manager=None): self.is_dragging = False self.shake_count = 0 self.last_shake_time = 0 - self.original_size = QSize(100, 100) + pet_size = self.config.get("pet_size") if self.config else 100 + self.original_size = QSize(pet_size, pet_size) # Начальный размер self.resize(self.original_size) @@ -91,6 +92,15 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size: int): + """Устанавливает базовый размер питомца в пикселях""" + if self.config: + self.config.set("pet_size", size) + self.original_size = QSize(size, size) + if not self.is_dragging: + self.resize(self.original_size) + self.animation_manager.update_size(self.size()) + def set_always_on_top(self, enabled): """Включает или выключает режим 'Поверх всех окон' динамически""" if self.config: diff --git a/src/ui/settings_dialog.py b/src/ui/settings_dialog.py index 5339824..529d80f 100644 --- a/src/ui/settings_dialog.py +++ b/src/ui/settings_dialog.py @@ -52,6 +52,21 @@ def __init__(self, config, parent=None): self.opacity_slider.valueChanged.connect(lambda v: self.opacity_val_label.setText(f"{v}%")) layout.addWidget(self.opacity_slider) + # Размер котика + pet_size_header_layout = QHBoxLayout() + pet_size_header_layout.addWidget(QLabel("Размер котика:")) + self.pet_size_val_label = QLabel(f"{self.config.get('pet_size')} px") + self.pet_size_val_label.setStyleSheet("font-weight: bold; color: #555;") + pet_size_header_layout.addStretch() + pet_size_header_layout.addWidget(self.pet_size_val_label) + layout.addLayout(pet_size_header_layout) + + self.pet_size_slider = QSlider(Qt.Horizontal) + self.pet_size_slider.setRange(50, 250) + self.pet_size_slider.setValue(self.config.get("pet_size")) + self.pet_size_slider.valueChanged.connect(lambda v: self.pet_size_val_label.setText(f"{v} px")) + layout.addWidget(self.pet_size_slider) + # Интервал растяжки layout.addWidget(QLabel("Интервал растяжки (мин):")) self.stretch_spin = QSpinBox() @@ -168,6 +183,7 @@ def save_settings(self): self.config.set("always_on_top", self.always_on_top_check.isChecked()) self.config.set("volume", self.volume_slider.value()) self.config.set("opacity", self.opacity_slider.value()) + self.config.set("pet_size", self.pet_size_slider.value()) self.config.set("stretch_interval", self.stretch_spin.value()) self.config.set("pomodoro_work", self.pomodoro_work_spin.value()) self.config.set("pomodoro_break", self.pomodoro_break_spin.value()) diff --git a/src/ui/tray_menu.py b/src/ui/tray_menu.py index b3d0392..9589ce9 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -30,6 +30,10 @@ def __init__(self, pet_window): self.window.timer_system.pomodoro_tick.connect(self.update_pomodoro_status) self.window.timer_system.pomodoro_finished.connect(self.on_pomodoro_finished) + # Подписка на сигнал изменения режима лазерной указки + if hasattr(self.window, "input_manager") and self.window.input_manager: + self.window.input_manager.laser_mode_changed.connect(self.laser_action.setChecked) + def setup_menu(self): # 1. Секция статуса Pomodoro self.status_action = QAction("Таймер не запущен", self) @@ -59,10 +63,10 @@ def setup_menu(self): self.menu.addSeparator() # Лазерная указка - laser_action = QAction("Лазерная указка 🔴", self) - laser_action.setCheckable(True) - laser_action.triggered.connect(self.toggle_laser) - self.menu.addAction(laser_action) + self.laser_action = QAction("Лазерная указка 🔴", self) + self.laser_action.setCheckable(True) + self.laser_action.triggered.connect(self.toggle_laser) + self.menu.addAction(self.laser_action) self.menu.addSeparator() @@ -183,9 +187,10 @@ def start_break_timer(self, checked=False): 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")) + self.window.set_pet_size(self.window.config.get("pet_size")) # Обновляем меню скинов self.update_skin_menu() # Обновляем режим "Поверх всех окон" в реальном времени @@ -232,7 +237,7 @@ def feed_pet(self, checked=False): def toggle_laser(self, checked): if self.window.input_manager: - is_active = self.window.input_manager.toggle_laser_mode() + is_active = self.window.input_manager.toggle_laser_mode(checked) if is_active: self.show_message("Мини-игра", "Лазерная указка активирована! 🔴") else: diff --git a/src/utils/config_manager.py b/src/utils/config_manager.py index e6ad878..fb39511 100644 --- a/src/utils/config_manager.py +++ b/src/utils/config_manager.py @@ -12,7 +12,8 @@ class ConfigManager: "volume": 70, "language": "ru", "skin": "default", - "opacity": 100 + "opacity": 100, + "pet_size": 100 } def __init__(self, config_path="settings.json"): diff --git a/tests/test_utils.py b/tests/test_utils.py index 1a72e44..7929eac 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -57,6 +57,7 @@ def test_always_on_top_logic(self): flags = window.windowFlags() self.assertFalse(bool(flags & Qt.WindowStaysOnTopHint)) self.assertFalse(config.get("always_on_top")) + window.close() def test_config_manager(self): config = ConfigManager(self.config_path) @@ -360,6 +361,7 @@ def test_delete_custom_skin(self): # Восстанавливаем моки гарантированно QMessageBox.question = original_question QMessageBox.information = original_information + dialog.close() def test_sound_manager_volume_override(self): from src.utils.sound_manager import SoundManager @@ -481,6 +483,95 @@ def test_input_manager_reset_logic(self): db.close() + def test_laser_mode_changed_signal_and_tray_sync(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from src.ui.tray_menu import TrayMenu + + app = QApplication.instance() or QApplication([]) + + config = ConfigManager(self.config_path) + window = PetWindow(config) + + db = DataStore(self.db_path) + im = InputManager(window, db) + window.input_manager = im + + tray = TrayMenu(window) + + # Сигнал от InputManager подключен к tray.laser_action.setChecked + self.assertFalse(tray.laser_action.isChecked()) + + im.toggle_laser_mode(True) + self.assertTrue(im.laser_mode) + self.assertTrue(tray.laser_action.isChecked()) + + im.toggle_laser_mode(False) + self.assertFalse(im.laser_mode) + self.assertFalse(tray.laser_action.isChecked()) + + tray.tray_icon.hide() + tray.tray_icon.deleteLater() + window.close() + db.close() + + def test_pet_size_configuration_and_scaling(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from src.ui.settings_dialog import SettingsDialog + + app = QApplication.instance() or QApplication([]) + + # 1. Проверка дефолтного значения в конфиге + config = ConfigManager(self.config_path) + self.assertEqual(config.get("pet_size"), 100) + + # 2. Инициализация PetWindow + window = PetWindow(config) + self.assertEqual(window.width(), 100) + self.assertEqual(window.height(), 100) + + # 3. Изменение размера через set_pet_size + window.set_pet_size(150) + self.assertEqual(window.width(), 150) + self.assertEqual(window.height(), 150) + self.assertEqual(config.get("pet_size"), 150) + + # 4. Проверка диалога настроек + dialog = SettingsDialog(config) + self.assertEqual(dialog.pet_size_slider.value(), 150) + dialog.pet_size_slider.setValue(200) + dialog.save_settings() + self.assertEqual(config.get("pet_size"), 200) + dialog.close() + + # 5. Проверка динамического радиуса поглаживания в InputManager + db = DataStore(self.db_path) + mock_window = MagicMock() + mock_window.config = config + mock_window.width.return_value = 200 + mock_window.height.return_value = 200 + mock_pos = MagicMock() + mock_pos.x.return_value = 100 + mock_pos.y.return_value = 100 + mock_window.get_cached_pos.return_value = mock_pos + mock_cursor = MagicMock() + mock_cursor.pos.return_value = QPoint(100, 100) + mock_window.cursor.return_value = mock_cursor + mock_window.animation_manager.current_state = "idle" + + im = InputManager(mock_window, db) + + # При pet_size = 200 радиус = 60 * 2 = 120px + # Движение мыши на расстоянии 110px от центра (200, 200) -> точка (200, 90) + im.handle_mouse(200, 90) + im.last_pet_time -= 1.0 # сбрасываем кулдаун + im.handle_mouse(200, 145) # сдвиг на 55px (больше 30px мазок) + self.assertEqual(im.pending_stats["petting_count"], 1) + + db.close() + window.close() + def test_stats_dialog_reset_ui_flow(self): from src.ui.stats_dialog import StatsDialog from PySide6.QtWidgets import QMessageBox, QApplication @@ -514,6 +605,8 @@ def test_stats_dialog_reset_ui_flow(self): finally: QMessageBox.question = original_question QMessageBox.information = original_information + dialog.update_timer.stop() + dialog.close() db.close() if __name__ == '__main__': diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 1fa59b4..19d0e6f 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ