# -*- coding: utf-8 -*- # gui/components/photo_view_widget.py """Виджет для отображения фотографий видов""" from pathlib import Path from PySide6.QtWidgets import QLabel, QSizePolicy from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QPixmap from gui.containers import VContainer, SContainer from gui.styles import APP_STYLES class PhotoViewWidget(SContainer): """Виджет для отображения фотографий видов""" error_occurred = Signal(str) def __init__(self, content_fit: bool = True): super().__init__(width_percent=None, height_percent=None) self._content_fit = content_fit self._current_zoom = 1.0 self._current_view = None self._images = {} # {имя_вида: QPixmap} self._base_pixmap = None self.setup_ui() def setup_ui(self): main_container = VContainer( content_fit=self._content_fit, parent=self, ) self._image_label = QLabel() self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._image_label.setMinimumSize(1, 1) # Важно: scaledContents=False, иначе некорректно комбинируется с зумом/KeepAspectRatio self._image_label.setScaledContents(False) # Устанавливаем фон как у главного окна self.setStyleSheet(APP_STYLES.get("VIEW_WIDGET_BACKGROUND", "")) self._image_label.setStyleSheet(APP_STYLES.get("VIEW_IMAGE_BACKGROUND", "")) main_container.add_widget_with_stretch(self._image_label, 1) def load_images(self, images_dict): """Загрузка изображений""" self.clear_images() if not images_dict: self.show_error("Изображения не указаны") return loaded_count = 0 for view_name, image_path in images_dict.items(): p = Path(image_path) if not p.exists(): continue pixmap = QPixmap(str(p)) if pixmap.isNull(): continue self._images[view_name] = pixmap loaded_count += 1 if loaded_count == 0: self.show_error("Не удалось загрузить ни одного изображения") return first_view = list(self._images.keys())[0] self.show_view(first_view) def clear_images(self): """Очистка изображений""" self._images.clear() self._current_view = None self._base_pixmap = None self._image_label.clear() self._current_zoom = 1.0 def show_view(self, view_name): """Показать изображение вида""" if view_name not in self._images: return self._current_view = view_name self._base_pixmap = self._images[view_name] self.update_display() def switch_view_cycle(self, preferred_order): """Циклическое переключение вида с предпочтительным порядком""" if not self._images or not self._current_view: return available_views = [v for v in preferred_order if v in self._images] if not available_views: return if self._current_view in available_views: current_index = available_views.index(self._current_view) next_index = (current_index + 1) % len(available_views) else: next_index = 0 self.show_view(available_views[next_index]) def switch_to_isometric_or_next(self): """Переключение на изометрический вид или следующий в цикле""" if not self._images: return if 'isometric' in self._images and self._current_view != 'isometric': self.show_view('isometric') return available_views = list(self._images.keys()) if not available_views: return if self._current_view in available_views: i = available_views.index(self._current_view) self.show_view(available_views[(i + 1) % len(available_views)]) else: self.show_view(available_views[0]) def update_display(self): """Обновление отображения изображения с учетом зума""" if self._base_pixmap is None or self._base_pixmap.isNull(): return # Базовый размер — размер label, дальше умножаем на zoom target = self._image_label.size() if target.width() <= 1 or target.height() <= 1: return w = max(1, int(target.width() * self._current_zoom)) h = max(1, int(target.height() * self._current_zoom)) scaled = self._base_pixmap.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) self._image_label.setPixmap(scaled) def zoom_in(self): self._current_zoom = min(self._current_zoom * 1.2, 5.0) self.update_display() def zoom_out(self): self._current_zoom = max(self._current_zoom / 1.2, 0.2) self.update_display() def reset_zoom(self): self._current_zoom = 1.0 self.update_display() def show_error(self, message): self.clear_images() self.error_occurred.emit(message) def wheelEvent(self, event): if self._images: delta = event.angleDelta().y() if delta > 0: self.zoom_in() else: self.zoom_out() event.accept() def resizeEvent(self, event): super().resizeEvent(event) self.update_display() def set_enabled(self, enabled: bool) -> None: self.setEnabled(enabled) def set_min_width(self, width: int) -> None: self.setMinimumWidth(width) def set_min_height(self, height: int) -> None: self.setMinimumHeight(height) def set_max_width(self, width: int) -> None: self.setMaximumWidth(width) def set_max_height(self, height: int) -> None: self.setMaximumHeight(height) def set_fixed_size(self, width: int, height: int) -> None: self.setMinimumSize(width, height) self.setMaximumSize(width, height) def set_tooltip(self, text: str) -> None: self.setToolTip(text) def set_size_policy(self, horizontal, vertical) -> None: self.setSizePolicy(horizontal, vertical) # --------------------------------------------------------------------------- # Module workflow notes # --------------------------------------------------------------------------- # # 1) Назначение модуля: # Виджет для отображения фотографий видов детали с поддержкой # зума (колесо мыши), циклического переключения видов и # масштабирования с сохранением пропорций (KeepAspectRatio). # # 2) Зависимости модуля: # Импорты: Path (pathlib), # QLabel, QSizePolicy (PySide6.QtWidgets), # Qt, Signal (PySide6.QtCore), # QPixmap (PySide6.QtGui), # VContainer, SContainer (gui.containers), # APP_STYLES (gui.styles) # Хост-класс / базовый класс: SContainer # Внешние библиотеки: PySide6 (обязательна) # # 3) Экспорт: # Класс PhotoViewWidget — виджет фотогалереи. # Сигнал: error_occurred(str). # Методы: load_images(dict), clear_images(), show_view(str), # switch_view_cycle(list), switch_to_isometric_or_next(), # update_display(), zoom_in(), zoom_out(), reset_zoom(), # show_error(). # # 4) Состояние (поля): # _content_fit: bool — режим подгонки # _current_zoom: float — текущий множитель зума (1.0 = 100%) # _current_view: str|None — имя текущего вида # _images: dict — {имя_вида: QPixmap} # _base_pixmap: QPixmap|None— исходный pixmap текущего вида # _image_label: QLabel — виджет отображения изображения # # 5) Последовательность действий и вызовов: # __init__(content_fit) # -> super().__init__(...) # -> setup_ui() — VContainer + QLabel с Expanding + stylesheet # load_images(images_dict) # -> clear_images() -> для каждого пути: Path.exists() -> QPixmap # -> show_view(first_view) # show_view(view_name) # -> _base_pixmap = _images[view_name] -> update_display() # update_display() # -> размер label * _current_zoom -> scaled(KeepAspectRatio, SmoothTransformation) # -> _image_label.setPixmap(scaled) # wheelEvent(event) # -> delta > 0: zoom_in() | delta < 0: zoom_out() # resizeEvent(event) # -> update_display() — адаптация к новому размеру # # 6) Побочные эффекты: # - Загружает файлы изображений с диска. # - Устанавливает stylesheet через APP_STYLES. # - Испускает error_occurred при ошибках. # # 7) Границы ответственности: # Модуль НЕ управляет источником изображений (каталогом). # НЕ редактирует файлы. НЕ подключается к theme_bus. # # 8) Обработка ошибок: # load_images() проверяет Path.exists() и QPixmap.isNull(). # show_error() очищает изображения и испускает error_occurred. # # 9) Инварианты и контракты: # - _current_zoom ∈ [0.2, 5.0]. # - scaledContents = False — масштабирование управляется вручную. # - Если _base_pixmap is None — update_display() не делает ничего. # # 10) Правило сопровождения: # Для добавления поддержки drag-and-drop или аннотаций — расширять # подкласс, не модифицировать этот файл. Зум-границы (0.2–5.0) — # константы в zoom_in/zoom_out.