PortProtonQt/portprotonqt/image_utils.py
Boris Yumankulov abec9bbef8
All checks were successful
Check Translations / check-translations (push) Successful in 15s
Code and build check / Check code (push) Successful in 1m21s
Code and build check / Build with uv (push) Successful in 47s
Move repo from git to gitea
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-01 15:21:32 +05:00

504 lines
23 KiB
Python
Raw Permalink 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.

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)