# ui/widgets/center_grid.py import os import logging from typing import List, Dict, Any from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QListView, QStyledItemDelegate, QStyle ) from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFontMetrics, QDrag from PyQt6.QtCore import ( Qt, QAbstractListModel, QModelIndex, QSize, QRect, QRunnable, QObject, pyqtSignal, QThreadPool, QMimeData, QUrl ) logger = logging.getLogger("ComfyGallery.CenterGrid") FileIdRole = Qt.ItemDataRole.UserRole + 1 FilePathRole = Qt.ItemDataRole.UserRole + 2 HasWorkflowRole = Qt.ItemDataRole.UserRole + 3 RatingRole = Qt.ItemDataRole.UserRole + 4 PixmapRole = Qt.ItemDataRole.UserRole + 5 class ThumbnailSignals(QObject): loaded = pyqtSignal(str, QImage) class ThumbnailRunnable(QRunnable): def __init__(self, filepath: str, target_size: int, signals: ThumbnailSignals): super().__init__() self.filepath = filepath self.target_size = target_size self.signals = signals def run(self): try: image = QImage(self.filepath) if not image.isNull(): scaled = image.scaled( self.target_size, self.target_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) self.signals.loaded.emit(self.filepath, scaled) except Exception as e: logger.debug(f"Ошибка загрузки миниатюры для {self.filepath}: {e}") class ImageModel(QAbstractListModel): def __init__(self, parent=None): super().__init__(parent) self.files: List[Dict[str, Any]] = [] self.pixmap_cache: Dict[str, QPixmap] = {} self.loading_paths = set() self.thread_pool = QThreadPool.globalInstance() self.signals = ThumbnailSignals() self.signals.loaded.connect(self._on_thumbnail_loaded) self.thumbnail_size = 130 def set_files(self, files: List[Dict[str, Any]]): self.beginResetModel() self.files = files self.pixmap_cache.clear() self.loading_paths.clear() self.endResetModel() def set_thumbnail_size(self, size: int): self.beginResetModel() self.thumbnail_size = size self.pixmap_cache.clear() self.loading_paths.clear() self.endResetModel() def rowCount(self, parent=QModelIndex()) -> int: return len(self.files) def supportedDragActions(self) -> Qt.DropAction: """ Указываем, что модель поддерживает копирование при Drag-and-Drop """ return Qt.DropAction.CopyAction def mimeTypes(self) -> List[str]: return ["text/uri-list"] def mimeData(self, indexes: List[QModelIndex]) -> QMimeData: """ Преобразует выделенные файлы в формат UriList для ОС и браузеров """ mime_data = QMimeData() urls = [] for index in indexes: if index.isValid(): filepath = self.data(index, FilePathRole) if filepath and os.path.exists(filepath): urls.append(QUrl.fromLocalFile(filepath)) mime_data.setUrls(urls) return mime_data def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: if not index.isValid() or index.row() >= len(self.files): return None file_data = self.files[index.row()] filepath = file_data["filepath"] if role == Qt.ItemDataRole.DisplayRole: return file_data["filename"] elif role == FileIdRole: return file_data["id"] elif role == FilePathRole: return filepath elif role == HasWorkflowRole: return bool(file_data["has_workflow"]) elif role == RatingRole: return file_data["rating"] elif role == PixmapRole: if filepath in self.pixmap_cache: return self.pixmap_cache[filepath] if filepath not in self.loading_paths: self.loading_paths.add(filepath) runnable = ThumbnailRunnable(filepath, self.thumbnail_size, self.signals) self.thread_pool.start(runnable) return None return None def _on_thumbnail_loaded(self, filepath: str, qimage: QImage): if filepath in self.loading_paths: self.loading_paths.remove(filepath) pixmap = QPixmap.fromImage(qimage) self.pixmap_cache[filepath] = pixmap for row, f in enumerate(self.files): if f["filepath"] == filepath: idx = self.index(row) self.dataChanged.emit(idx, idx, [PixmapRole]) break class ImageDelegate(QStyledItemDelegate): def __init__(self, model: ImageModel, parent=None): super().__init__(parent) self.model = model def paint(self, painter: QPainter, option: "QStyleOptionViewItem", index: QModelIndex): painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing) rect = option.rect selected = option.state & QStyle.StateFlag.State_Selected hovered = option.state & QStyle.StateFlag.State_MouseOver bg_color = QColor("#1c1c1c") if hovered else QColor("#141414") if selected: bg_color = QColor("#0d47a1") painter.setBrush(QBrush(bg_color)) painter.setPen(QPen(QColor("#1976d2") if selected else QColor("#2d2d2d"), 1.5)) painter.drawRoundedRect(rect.adjusted(2, 2, -2, -2), 6, 6) cell_size = self.model.thumbnail_size img_rect = QRect(rect.left() + 8, rect.top() + 8, cell_size - 16, cell_size - 16) pixmap = index.data(PixmapRole) if pixmap and not pixmap.isNull(): w, h = pixmap.width(), pixmap.height() target_w, target_h = img_rect.width(), img_rect.height() if w > 0 and h > 0: ratio = min(target_w / w, target_h / h) final_w, final_h = int(w * ratio), int(h * ratio) x_offset = img_rect.left() + (target_w - final_w) // 2 y_offset = img_rect.top() + (target_h - final_h) // 2 painter.drawPixmap(x_offset, y_offset, final_w, final_h, pixmap) else: painter.setBrush(QBrush(QColor("#0a0a0a"))) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(img_rect, 4, 4) text = index.data(Qt.ItemDataRole.DisplayRole) if text: font = painter.font() font.setPointSize(8) painter.setFont(font) fm = QFontMetrics(font) text_rect = QRect(rect.left() + 4, rect.bottom() - 20, rect.width() - 8, 16) elided_text = fm.elidedText(text, Qt.TextElideMode.ElideMiddle, text_rect.width()) painter.setPen(QPen(QColor("#ffffff") if selected else QColor("#8e8e8e"))) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_text) has_workflow = index.data(HasWorkflowRole) if has_workflow: badge_rect = QRect(rect.right() - 22, rect.top() + 8, 14, 14) painter.setBrush(QBrush(QColor("#2ea44f"))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(badge_rect) painter.setPen(QPen(QColor("#ffffff"))) font = painter.font() font.setBold(True) font.setPointSize(7) painter.setFont(font) painter.drawText(badge_rect.adjusted(0, -1, 0, 0), Qt.AlignmentFlag.AlignCenter, "W") painter.restore() def sizeHint(self, option, index) -> QSize: size = self.model.thumbnail_size return QSize(size, size + 22) class GalleryListView(QListView): delete_pressed = pyqtSignal(list) copy_pressed = pyqtSignal(str) def keyPressEvent(self, event): if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C: indexes = self.selectedIndexes() if indexes: filepath = self.model().data(indexes[0], FilePathRole) if filepath: self.copy_pressed.emit(filepath) event.accept() return if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): indexes = self.selectedIndexes() if indexes: paths = [self.model().data(idx, FilePathRole) for idx in indexes if idx.isValid()] self.delete_pressed.emit(paths) event.accept() return super().keyPressEvent(event) def startDrag(self, supportedActions): """ Переопределяем метод начала перетаскивания. Принудительно инициализирует системное событие Drag-and-Drop для внешних окон. """ indexes = self.selectedIndexes() if not indexes: return logger.debug(f"Начало перетаскивания файлов: {len(indexes)} шт.") drag = QDrag(self) mime_data = self.model().mimeData(indexes) drag.setMimeData(mime_data) # Установка миниатюры под курсор при перетаскивании pixmap = self.model().data(indexes[0], PixmapRole) if pixmap and not pixmap.isNull(): drag.setPixmap(pixmap.scaled(70, 70, Qt.AspectRatioMode.KeepAspectRatio)) drag.exec(Qt.DropAction.CopyAction) class CenterGrid(QWidget): def __init__(self, parent=None): super().__init__(parent) self.init_ui() def init_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) control_layout = QHBoxLayout() self.path_label = QLabel("Выберите папку...") control_layout.addWidget(self.path_label) control_layout.addStretch() control_layout.addWidget(QLabel("Масштаб:")) self.zoom_slider = QSlider(Qt.Orientation.Horizontal) self.zoom_slider.setRange(80, 250) self.zoom_slider.setValue(130) self.zoom_slider.setFixedWidth(150) control_layout.addWidget(self.zoom_slider) layout.addLayout(control_layout) self.list_view = GalleryListView() self.list_view.setViewMode(QListView.ViewMode.IconMode) self.list_view.setResizeMode(QListView.ResizeMode.Adjust) self.list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection) self.list_view.setDragEnabled(True) self.list_view.setSpacing(10) self.list_view.setUniformItemSizes(True) self.list_view.setStyleSheet("QListView { border: 1px solid #2d2d2d; background-color: #121212; }") self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.model = ImageModel() self.delegate = ImageDelegate(self.model) self.list_view.setModel(self.model) self.list_view.setItemDelegate(self.delegate) layout.addWidget(self.list_view) self.zoom_slider.valueChanged.connect(self._on_zoom_changed) def _on_zoom_changed(self, value: int): self.model.set_thumbnail_size(value)