324 lines
15 KiB
Python
324 lines
15 KiB
Python
# -*- 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
|