import os from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication import portprotonqt.themes.standart.styles as default_styles from portprotonqt.config_utils import read_theme_from_config from portprotonqt.theme_manager import ThemeManager from portprotonqt.downloader import Downloader from portprotonqt.logger import get_logger from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from queue import Queue import threading downloader = Downloader() logger = get_logger(__name__) # Глобальная очередь и пул потоков для загрузки изображений image_load_queue = Queue() image_executor = ThreadPoolExecutor(max_workers=4) queue_lock = threading.Lock() def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""): """ Асинхронно загружает обложку через очередь задач. """ def process_image(): theme_manager = ThemeManager() current_theme_name = read_theme_from_config() def finish_with(pixmap: QPixmap): scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) x = (scaled.width() - width) // 2 y = (scaled.height() - height) // 2 cropped = scaled.copy(x, y, width, height) callback(cropped) # Removed: pixmap = None (unnecessary, causes type error) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) image_folder = os.path.join(xdg_cache_home, "PortProtonQT", "images") os.makedirs(image_folder, exist_ok=True) if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"): try: parts = cover.split("/") appid = None if "apps" in parts: idx = parts.index("apps") if idx + 1 < len(parts): appid = parts[idx + 1] if appid: local_path = os.path.join(image_folder, f"{appid}.jpg") if os.path.exists(local_path): pixmap = QPixmap(local_path) finish_with(pixmap) return def on_downloaded(result: str | None): pixmap = QPixmap() if result and os.path.exists(result): pixmap.load(result) if pixmap.isNull(): placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name) if placeholder_path and QFile.exists(placeholder_path): pixmap.load(placeholder_path) else: pixmap = QPixmap(width, height) pixmap.fill(QColor("#333333")) painter = QPainter(pixmap) painter.setPen(QPen(QColor("white"))) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image") painter.end() finish_with(pixmap) downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded) return except Exception as e: logger.error(f"Ошибка обработки URL {cover}: {e}") if cover and cover.startswith(("http://", "https://")): try: local_path = os.path.join(image_folder, f"{app_name}.jpg") if os.path.exists(local_path): pixmap = QPixmap(local_path) finish_with(pixmap) return def on_downloaded(result: str | None): pixmap = QPixmap() if result and os.path.exists(result): pixmap.load(result) if pixmap.isNull(): placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name) if placeholder_path and QFile.exists(placeholder_path): pixmap.load(placeholder_path) else: pixmap = QPixmap(width, height) pixmap.fill(QColor("#333333")) painter = QPainter(pixmap) painter.setPen(QPen(QColor("white"))) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image") painter.end() finish_with(pixmap) downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded) return except Exception as e: logger.error("Error processing EGS URL %s: %s", cover, str(e)) if cover and QFile.exists(cover): pixmap = QPixmap(cover) finish_with(pixmap) return placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name) pixmap = QPixmap() if placeholder_path and QFile.exists(placeholder_path): pixmap.load(placeholder_path) else: pixmap = QPixmap(width, height) pixmap.fill(QColor("#333333")) painter = QPainter(pixmap) painter.setPen(QPen(QColor("white"))) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image") painter.end() finish_with(pixmap) with queue_lock: image_load_queue.put(process_image) image_executor.submit(lambda: image_load_queue.get()()) def round_corners(pixmap, radius): """ Возвращает QPixmap с закруглёнными углами. """ if pixmap.isNull(): return pixmap size = pixmap.size() rounded = QPixmap(size) rounded.fill(QColor(0, 0, 0, 0)) painter = QPainter(rounded) painter.setRenderHint(QPainter.RenderHint.Antialiasing) path = QPainterPath() path.addRoundedRect(0, 0, size.width(), size.height(), radius, radius) painter.setClipPath(path) painter.drawPixmap(0, 0, pixmap) painter.end() return rounded class FullscreenDialog(QDialog): """ Диалог для просмотра изображений без стандартных элементов управления. Изображение отображается в области фиксированного размера, а подпись располагается чуть выше нижней границы. В окне есть кнопки-стрелки для перелистывания изображений. Диалог закрывается при клике по изображению или подписи. """ FIXED_WIDTH = 800 FIXED_HEIGHT = 400 def __init__(self, images, current_index=0, parent=None, theme=None): """ :param images: Список кортежей (QPixmap, caption) :param current_index: Индекс текущего изображения :param theme: Объект темы для стилизации (если None, используется default_styles) """ super().__init__(parent) # Удаление диалога после закрытия self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocus() self.images = images self.current_index = current_index self.theme = theme if theme else default_styles # Убираем стандартные элементы управления окна self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.init_ui() self.update_display() # Фильтруем события для закрытия диалога по клику self.imageLabel.installEventFilter(self) self.captionLabel.installEventFilter(self) def init_ui(self): self.mainLayout = QVBoxLayout(self) self.setLayout(self.mainLayout) self.mainLayout.setContentsMargins(0, 0, 0, 0) self.mainLayout.setSpacing(0) # Контейнер для изображения и стрелок self.imageContainer = QWidget() self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT) self.imageContainerLayout = QHBoxLayout(self.imageContainer) self.imageContainerLayout.setContentsMargins(0, 0, 0, 0) self.imageContainerLayout.setSpacing(0) # Левая стрелка self.prevButton = QToolButton() self.prevButton.setArrowType(Qt.ArrowType.LeftArrow) self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE) self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor) self.prevButton.setFixedSize(40, 40) self.prevButton.clicked.connect(self.show_prev) self.imageContainerLayout.addWidget(self.prevButton) # Метка для изображения self.imageLabel = QLabel() self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT) self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.imageContainerLayout.addWidget(self.imageLabel, stretch=1) # Правая стрелка self.nextButton = QToolButton() self.nextButton.setArrowType(Qt.ArrowType.RightArrow) self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor) self.nextButton.setFixedSize(40, 40) self.nextButton.clicked.connect(self.show_next) self.imageContainerLayout.addWidget(self.nextButton) self.mainLayout.addWidget(self.imageContainer) # Небольшой отступ между изображением и подписью spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.mainLayout.addItem(spacer) # Подпись self.captionLabel = QLabel() self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.captionLabel.setFixedHeight(40) self.captionLabel.setWordWrap(True) self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE) self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor) self.mainLayout.addWidget(self.captionLabel) def update_display(self): """Обновляет изображение и подпись согласно текущему индексу.""" if not self.images: return # Очищаем старое содержимое self.imageLabel.clear() self.captionLabel.clear() QApplication.processEvents() pixmap, caption = self.images[self.current_index] # Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера scaled_pixmap = pixmap.scaled( self.FIXED_WIDTH - 80, # учитываем ширину стрелок self.FIXED_HEIGHT, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) self.imageLabel.setPixmap(scaled_pixmap) self.captionLabel.setText(caption) self.setWindowTitle(caption) # Принудительная перерисовка виджетов self.imageLabel.repaint() self.captionLabel.repaint() self.repaint() def show_prev(self): """Показывает предыдущее изображение.""" if self.images: self.current_index = (self.current_index - 1) % len(self.images) self.update_display() def show_next(self): """Показывает следующее изображение.""" if self.images: self.current_index = (self.current_index + 1) % len(self.images) self.update_display() def eventFilter(self, obj, event): """Закрывает диалог при клике по изображению или подписи.""" if event.type() == QEvent.Type.MouseButtonPress and obj in [self.imageLabel, self.captionLabel]: self.close() return True return super().eventFilter(obj, event) def changeEvent(self, event): """Закрывает диалог при потере фокуса.""" if event.type() == QEvent.Type.ActivationChange: if not self.isActiveWindow(): self.close() super().changeEvent(event) def mousePressEvent(self, event): """Закрывает диалог при клике на пустую область.""" pos = event.pos() # Проверяем, находится ли клик вне imageContainer и captionLabel if not (self.imageContainer.geometry().contains(pos) or self.captionLabel.geometry().contains(pos)): self.close() super().mousePressEvent(event) class ClickablePixmapItem(QGraphicsPixmapItem): """ Элемент карусели, реагирующий на клик. При клике открывается FullscreenDialog с возможностью перелистывания изображений. """ def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None): """ :param pixmap: QPixmap для отображения в карусели :param caption: Подпись к изображению :param images_list: Список всех изображений (кортежей (QPixmap, caption)), чтобы в диалоге можно было перелистывать. Если не передан, будет использован только текущее изображение. :param index: Индекс текущего изображения в images_list. :param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками. """ super().__init__(pixmap) self.caption = caption self.images_list = images_list if images_list is not None else [(pixmap, caption)] self.index = index self.carousel = carousel self.setCursor(Qt.CursorShape.PointingHandCursor) self.setToolTip(caption) self._click_start_position = None self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._click_start_position = event.scenePos() event.accept() def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton and self._click_start_position is not None: distance = (event.scenePos() - self._click_start_position).manhattanLength() if distance < 2: self.show_fullscreen() event.accept() return event.accept() def show_fullscreen(self): # Скрываем стрелки карусели перед открытием FullscreenDialog if self.carousel: self.carousel.prevArrow.hide() self.carousel.nextArrow.hide() dialog = FullscreenDialog(self.images_list, current_index=self.index) dialog.exec() # После закрытия диалога обновляем видимость стрелок if self.carousel: self.carousel.update_arrows_visibility() class ImageCarousel(QGraphicsView): """ Карусель изображений с адаптивностью, возможностью увеличения по клику и перетаскиванием мыши. """ def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None): super().__init__(parent) # Аннотируем тип scene как QGraphicsScene self.carousel_scene: QGraphicsScene = QGraphicsScene(self) self.setScene(self.carousel_scene) self.images = images # Список кортежей: (QPixmap, caption) self.image_items = [] self._animation = None self.theme = theme if theme else default_styles self.init_ui() self.create_arrows() # Переменные для поддержки перетаскивания self._drag_active = False self._drag_start_position = None self._scroll_start_value = None def init_ui(self): self.setRenderHint(QPainter.RenderHint.Antialiasing) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setFrameShape(QFrame.Shape.NoFrame) x_offset = 10 # Отступ между изображениями max_height = 300 # Фиксированная высота изображений x = 0 for i, (pixmap, caption) in enumerate(self.images): item = ClickablePixmapItem( pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation), caption, images_list=self.images, index=i, carousel=self # Передаем ссылку на карусель ) item.setPos(x, 0) self.carousel_scene.addItem(item) self.image_items.append(item) x += item.pixmap().width() + x_offset self.setSceneRect(0, 0, x, max_height) def create_arrows(self): """Создаёт кнопки-стрелки и привязывает их к функциям прокрутки.""" self.prevArrow = QToolButton(self) self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow) self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore self.prevArrow.setFixedSize(40, 40) self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor) self.prevArrow.setAutoRepeat(True) self.prevArrow.setAutoRepeatDelay(300) self.prevArrow.setAutoRepeatInterval(100) self.prevArrow.clicked.connect(self.scroll_left) self.prevArrow.raise_() self.nextArrow = QToolButton(self) self.nextArrow.setArrowType(Qt.ArrowType.RightArrow) self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore self.nextArrow.setFixedSize(40, 40) self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor) self.nextArrow.setAutoRepeat(True) self.nextArrow.setAutoRepeatDelay(300) self.nextArrow.setAutoRepeatInterval(100) self.nextArrow.clicked.connect(self.scroll_right) self.nextArrow.raise_() # Проверяем видимость стрелок при создании self.update_arrows_visibility() def update_arrows_visibility(self): """ Показывает стрелки, если контент шире видимой области. Иначе скрывает их. """ if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"): if self.horizontalScrollBar().maximum() == 0: self.prevArrow.hide() self.nextArrow.hide() else: self.prevArrow.show() self.nextArrow.show() def resizeEvent(self, event): super().resizeEvent(event) margin = 10 self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2) self.nextArrow.move(self.width() - self.nextArrow.width() - margin, (self.height() - self.nextArrow.height()) // 2) self.update_arrows_visibility() def animate_scroll(self, end_value): scrollbar = self.horizontalScrollBar() start_value = scrollbar.value() animation = QPropertyAnimation(scrollbar, QByteArray(b"value"), self) animation.setDuration(300) animation.setStartValue(start_value) animation.setEndValue(end_value) animation.setEasingCurve(QEasingCurve.Type.InOutQuad) self._animation = animation animation.start() def scroll_left(self): scrollbar = self.horizontalScrollBar() new_value = scrollbar.value() - 100 self.animate_scroll(new_value) def scroll_right(self): scrollbar = self.horizontalScrollBar() new_value = scrollbar.value() + 100 self.animate_scroll(new_value) def update_images(self, new_images): self.carousel_scene.clear() self.images = new_images self.image_items.clear() self.init_ui() self.update_arrows_visibility() # Обработка событий мыши для перетаскивания def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._drag_active = True self._drag_start_position = event.pos() self._scroll_start_value = self.horizontalScrollBar().value() # Скрываем стрелки при начале перетаскивания if hasattr(self, "prevArrow"): self.prevArrow.hide() if hasattr(self, "nextArrow"): self.nextArrow.hide() super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._drag_active and self._drag_start_position is not None: delta = event.pos().x() - self._drag_start_position.x() new_value = self._scroll_start_value - delta self.horizontalScrollBar().setValue(new_value) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): self._drag_active = False # Показываем стрелки после завершения перетаскивания (с проверкой видимости) self.update_arrows_visibility() super().mouseReleaseEvent(event)