Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
# -*- 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