# -*- coding: utf-8 -*- # gui/components/model_view/_interaction_scenario.py """Базовый сценарий взаимодействия и менеджер сценариев. Архитектура: - InteractionScenario — базовый класс с методами-хуками для событий. - CameraPolicy — декларативная политика камеры для сценария. - InteractionManager — стек сценариев + делегация событий. """ from __future__ import annotations from enum import Enum, auto from typing import TYPE_CHECKING from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QGuiApplication from error_logger import log_exception if TYPE_CHECKING: # pragma: no cover from gui.components.model_view_widget import ModelViewWidget # ── CameraPolicy ──────────────────────────────────────────────────────────── class CameraPolicy(Enum): """Политика камеры для сценария.""" FREE = auto() # ПКМ вращение, СКМ панорамирование TOP_VIEW = auto() # Только pan/zoom, без вращения LOCKED = auto() # Камера заблокирована # ── InteractionScenario ───────────────────────────────────────────────────── class InteractionScenario: """Базовый класс сценария взаимодействия. Каждый конкретный сценарий переопределяет нужные хуки. Менеджер передаёт события текущему сценарию через эти методы. """ name: str = "" camera_policy: CameraPolicy = CameraPolicy.FREE def on_activate(self, mv: "ModelViewWidget") -> None: """Вызывается при входе в сценарий (push/replace).""" def on_deactivate(self, mv: "ModelViewWidget") -> None: """Вызывается при выходе из сценария (pop/replace/reset).""" def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool: """ЛКМ/ПКМ/СКМ нажатие. Вернуть True если событие поглощено.""" return False def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool: """Движение мыши (с нажатой кнопкой). Вернуть True если поглощено.""" return False def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool: """Отпускание кнопки мыши. Вернуть True если поглощено.""" return False def on_hover(self, mv: "ModelViewWidget", event) -> None: """Движение мыши без нажатых кнопок (hover).""" def on_key_press(self, mv: "ModelViewWidget", event) -> bool: """Нажатие клавиши. Вернуть True если поглощено.""" return False def on_resume(self, mv: "ModelViewWidget") -> None: """Вызывается когда сценарий снова стал верхним (после pop вышестоящего). По умолчанию вызывает on_activate повторно, чтобы восстановить обработчики. """ self.on_activate(mv) def on_widget_mouse_press(self, mv: "ModelViewWidget", event) -> bool: """mousePressEvent виджета (не eventFilter). Вернуть True если поглощено.""" return False def on_widget_mouse_move(self, mv: "ModelViewWidget", event) -> bool: """mouseMoveEvent виджета. Вернуть True если поглощено.""" return False def on_widget_mouse_release(self, mv: "ModelViewWidget", event) -> bool: """mouseReleaseEvent виджета. Вернуть True если поглощено.""" return False # ── InteractionManager ────────────────────────────────────────────────────── class InteractionManager: """Стек сценариев взаимодействия. - Всегда есть base-layer (камерный сценарий) — он не в стеке. - Стек содержит доменные сценарии (contour_edit, rack_placement...). - Верхний элемент стека — текущий сценарий. - ESC → pop() → автоматический on_deactivate(). - reset() → очищает весь стек. """ def __init__(self, mv: "ModelViewWidget") -> None: self._mv = mv self._stack: list[InteractionScenario] = [] self._camera: InteractionScenario | None = None # -- Публичный API -------------------------------------------------------- def set_camera_scenario(self, scenario: InteractionScenario) -> None: """Установить базовый камерный сценарий (всегда активен).""" self._camera = scenario @property def current(self) -> InteractionScenario | None: """Текущий (верхний) доменный сценарий, или None.""" return self._stack[-1] if self._stack else None @property def current_name(self) -> str: """Имя текущего сценария.""" top = self.current return top.name if top else "" def is_active(self, name: str) -> bool: """Проверить, есть ли сценарий с данным именем в стеке.""" key = str(name) return any(s.name == key for s in self._stack) def push(self, scenario: InteractionScenario) -> None: """Войти в новый сценарий (добавить на вершину стека).""" scenario.on_activate(self._mv) self._stack.append(scenario) def pop(self) -> InteractionScenario | None: """Выйти из текущего сценария, вернуть его.""" if not self._stack: return None scenario = self._stack.pop() scenario.on_deactivate(self._mv) # Восстановить обработчики нижележащего сценария new_top = self.current if new_top is not None: new_top.on_resume(self._mv) return scenario def pop_by_name(self, name: str) -> InteractionScenario | None: """Удалить сценарий по имени (из любого места стека).""" key = str(name) for i, s in enumerate(self._stack): if s.name == key: was_top = (i == len(self._stack) - 1) scenario = self._stack.pop(i) scenario.on_deactivate(self._mv) if was_top: new_top = self.current if new_top is not None: new_top.on_resume(self._mv) return scenario return None def replace(self, scenario: InteractionScenario) -> InteractionScenario | None: """Заменить текущий сценарий новым (без промежуточного on_resume).""" old = None if self._stack: old = self._stack.pop() old.on_deactivate(self._mv) self.push(scenario) return old def reset(self) -> None: """Сбросить весь стек (ESC-уровень).""" while self._stack: scenario = self._stack.pop() try: scenario.on_deactivate(self._mv) except Exception as e: log_exception(__name__, "reset", e) # -- Диспетчеризация событий eventFilter ---------------------------------- def dispatch_event_filter(self, watched, event) -> bool | None: """Маршрутизация события из eventFilter. Returns: True — событие поглощено. False — событие НЕ должно обрабатываться дальше. None — передать в super().eventFilter(). """ top = self.current etype = event.type() # 1. Нажатие клавиши → текущий сценарий if etype == QEvent.Type.KeyPress: if top and top.on_key_press(self._mv, event): return True # 2. Mouse press/move/release → текущий сценарий, затем камера if etype == QEvent.Type.MouseButtonPress: if top and top.on_mouse_press(self._mv, event): return True if self._camera and self._dispatch_camera_press(event): return True if etype == QEvent.Type.MouseMove: # robust-классификация drag vs hover: # учитываем Qt event buttons, глобальные кнопки и внутренние drag-флаги камеры. ev_buttons = Qt.MouseButton.NoButton global_buttons = Qt.MouseButton.NoButton try: if hasattr(event, "buttons"): ev_buttons = event.buttons() except Exception as e: log_exception(__name__, "dispatch_event_filter", e) ev_buttons = Qt.MouseButton.NoButton try: global_buttons = QGuiApplication.mouseButtons() except Exception as e: log_exception(__name__, "dispatch_event_filter", e) global_buttons = Qt.MouseButton.NoButton has_buttons = bool( ev_buttons or global_buttons or getattr(self._mv, "_cam_rotate_active", False) or getattr(self._mv, "_cam_pan_active", False) ) if has_buttons: # Движение с зажатой кнопкой if top and top.on_mouse_move(self._mv, event): return True cam_result = self._dispatch_camera_move(event) if cam_result is not None: return cam_result # Не отдаём drag-события в VTK-style, чтобы исключить побочный захват камеры. return False else: # Hover (без кнопок) if top: top.on_hover(self._mv, event) # В origin-flow hover нужен для preview-маркера, # но не должен уходить в VTK interactor style. if top.name == "origin_point": # Защита от ложного swallow после завершения origin-сценария: # поглощаем hover только пока реально подключены origin-hover handlers. has_hover_handlers = bool( getattr(self._mv, "_hover_screen_handler", None) or getattr(self._mv, "_hover_handler", None) ) if has_hover_handlers: return True # Камера не обрабатывает hover if etype == QEvent.Type.MouseButtonRelease: if top and top.on_mouse_release(self._mv, event): return True cam_result = self._dispatch_camera_release(event) if cam_result is not None: return cam_result return None # -- Диспетчеризация mousePressEvent/Move/Release виджета ----------------- def dispatch_widget_mouse_press(self, event) -> bool: """Делегация mousePressEvent виджета текущему сценарию.""" top = self.current if top and top.on_widget_mouse_press(self._mv, event): return True return False def dispatch_widget_mouse_move(self, event) -> bool: """Делегация mouseMoveEvent виджета текущему сценарию.""" top = self.current if top and top.on_widget_mouse_move(self._mv, event): return True return False def dispatch_widget_mouse_release(self, event) -> bool: """Делегация mouseReleaseEvent виджета текущему сценарию.""" top = self.current if top and top.on_widget_mouse_release(self._mv, event): return True return False # -- Камерные делегации --------------------------------------------------- def _dispatch_camera_press(self, event) -> bool: if not self._camera: return False if getattr(self._mv, "_camera_locked", False): return False top = self.current policy = top.camera_policy if top else CameraPolicy.FREE if policy == CameraPolicy.LOCKED: return False # TOP_VIEW: блокировать вращение (ПКМ), разрешить панорамирование (СКМ) if policy == CameraPolicy.TOP_VIEW: if hasattr(event, "button") and event.button() == Qt.MouseButton.RightButton: # Поглощаем ПКМ, чтобы событие не ушло в VTK-style (иначе возможен zoom/pan). return True return self._camera.on_mouse_press(self._mv, event) def _dispatch_camera_move(self, event) -> bool | None: if not self._camera: return None if getattr(self._mv, "_camera_locked", False): return None top = self.current policy = top.camera_policy if top else CameraPolicy.FREE if policy == CameraPolicy.LOCKED: return None return self._camera.on_mouse_move(self._mv, event) or None def _dispatch_camera_release(self, event) -> bool | None: if not self._camera: return None if getattr(self._mv, "_camera_locked", False): return None top = self.current policy = top.camera_policy if top else CameraPolicy.FREE if policy == CameraPolicy.TOP_VIEW: if hasattr(event, "button") and event.button() == Qt.MouseButton.RightButton: return True return self._camera.on_mouse_release(self._mv, event) or None