Files
Dispatch/Dispatch_V0.1.1/gui/components/model_view/_interaction_scenario.py
2026-04-29 08:18:54 +04:00

324 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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