import logging from pathlib import Path from typing import List, Optional from PySide6.QtCore import QSize, Qt, Signal from PySide6.QtGui import QPixmap from PySide6.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QSplitter, QVBoxLayout, QWidget, ) from core.models import ImageAsset from services.file_service import scan_image_folder logger = logging.getLogger(__name__) _THUMB_SIZE = 84 # thumbnail size in px _CARD_W = 104 # grid cell width _CARD_H = 116 # grid cell height # CheckState int values (avoids enum import issues across PySide6 versions) _UNCHECKED = 0 _PARTIAL = 1 _CHECKED = 2 class _AssetItemWidget(QWidget): """Grid card: thumbnail with checkbox overlay on top, filename below.""" check_changed = Signal() def __init__( self, asset: ImageAsset, root_folder: Optional[Path] = None, parent=None, ): super().__init__(parent) self._asset = asset self._root_folder = root_folder # Required so setStyleSheet() paints the widget background self.setAttribute(Qt.WA_StyledBackground, True) self._setup_ui() def _setup_ui(self): self.setObjectName("assetCard") self.setFixedSize(_CARD_W, _CARD_H) col = QVBoxLayout(self) col.setContentsMargins(4, 4, 4, 4) col.setSpacing(3) # Thumbnail with the batch-selection checkbox overlaid on its corner thumb_box = QWidget() thumb_box.setFixedSize(_THUMB_SIZE, _THUMB_SIZE) self._thumb = QLabel(thumb_box) self._thumb.setObjectName("thumbLabel") self._thumb.setGeometry(0, 0, _THUMB_SIZE, _THUMB_SIZE) self._thumb.setAlignment(Qt.AlignCenter) self._load_thumbnail() self._check = QCheckBox(thumb_box) self._check.setObjectName("cardCheck") self._check.setChecked(self._asset.selected) self._check.stateChanged.connect(self._on_check_changed) self._check.move(3, 3) thumb_row = QHBoxLayout() thumb_row.setContentsMargins(0, 0, 0, 0) thumb_row.addStretch() thumb_row.addWidget(thumb_box) thumb_row.addStretch() col.addLayout(thumb_row) # Filename (middle-elided to fit the card width) name = Path(self._asset.path).name self._name_label = QLabel() self._name_label.setObjectName("fileNameLabel") self._name_label.setAlignment(Qt.AlignHCenter | Qt.AlignTop) elided = self._name_label.fontMetrics().elidedText( name, Qt.ElideMiddle, _CARD_W - 10 ) self._name_label.setText(elided) col.addWidget(self._name_label) col.addStretch() # Subfolder + filename shown on hover (no room for a label in the grid) subfolder = self._compute_subfolder() self.setToolTip("{}/{}".format(subfolder, name) if subfolder else name) def _compute_subfolder(self) -> str: asset_path = Path(self._asset.path) if self._root_folder: try: rel = asset_path.parent.relative_to(self._root_folder) s = str(rel) return "" if s == "." else s except ValueError: pass name = asset_path.parent.name return name if name else "" def _load_thumbnail(self): try: pix = QPixmap(str(self._asset.path)) if not pix.isNull(): pix = pix.scaled( _THUMB_SIZE, _THUMB_SIZE, Qt.KeepAspectRatio, Qt.SmoothTransformation, ) self._thumb.setPixmap(pix) return except Exception: pass self._thumb.setText("?") def _on_check_changed(self): self._asset.selected = self._check.isChecked() self.check_changed.emit() @property def asset(self) -> ImageAsset: return self._asset def set_checked(self, checked: bool): self._check.blockSignals(True) self._check.setChecked(checked) self._check.blockSignals(False) self._asset.selected = checked def set_preview_selected(self, active: bool): """Highlight or clear the current-preview indicator.""" if active: self.setStyleSheet( "#assetCard { background-color: #cce4f7; border-radius: 4px; }" ) else: self.setStyleSheet("") class _AssetPanel(QWidget): """Panel for one asset type (garment or print).""" preview_changed = Signal(object) # ImageAsset | None assets_changed = Signal(list) # List[ImageAsset] folder_opened = Signal(str) # chosen folder path (for persistence) def __init__(self, title: str, open_text: str, parent=None): super().__init__(parent) self._assets: List[ImageAsset] = [] self._root_folder: Optional[Path] = None self._current_preview_row: int = -1 self._start_dir: str = "" # where the folder picker opens self._setup_ui(title, open_text) # ── UI construction ──────────────────────────────────────────────── def _setup_ui(self, title: str, open_text: str): col = QVBoxLayout(self) col.setContentsMargins(0, 0, 0, 0) col.setSpacing(0) col.addWidget(self._make_header(title, open_text)) col.addWidget(self._make_ctrl_row()) self._list = QListWidget() self._list.setObjectName("assetList") # Thumbnail grid: wrap items left-to-right, reflow on resize self._list.setViewMode(QListWidget.IconMode) self._list.setFlow(QListWidget.LeftToRight) self._list.setWrapping(True) self._list.setResizeMode(QListWidget.Adjust) self._list.setMovement(QListWidget.Static) self._list.setUniformItemSizes(True) self._list.setGridSize(QSize(_CARD_W + 6, _CARD_H + 6)) self._list.setSpacing(2) self._list.setVerticalScrollMode(QListWidget.ScrollPerPixel) self._list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Disable QListWidget's own selection highlight — preview uses custom style self._list.setSelectionMode(QListWidget.NoSelection) self._list.itemClicked.connect(self._on_item_clicked) col.addWidget(self._list, 1) def _make_header(self, title: str, open_text: str) -> QWidget: header = QWidget() header.setObjectName("assetPanelHeader") row = QHBoxLayout(header) row.setContentsMargins(8, 5, 8, 5) title_lbl = QLabel(title) title_lbl.setObjectName("assetPanelTitle") self._open_btn = QPushButton(open_text) self._open_btn.setObjectName("openFolderBtn") self._open_btn.clicked.connect(self._open_folder) row.addWidget(title_lbl) row.addStretch() row.addWidget(self._open_btn) return header def _make_ctrl_row(self) -> QWidget: ctrl = QWidget() ctrl.setObjectName("ctrlRow") row = QHBoxLayout(ctrl) row.setContentsMargins(8, 3, 8, 3) row.setSpacing(4) self._select_all_cb = QCheckBox("全选") self._select_all_cb.setTristate(True) self._select_all_cb.stateChanged.connect(self._on_select_all_changed) self._stats_lbl = QLabel("共 0") self._stats_lbl.setObjectName("statsLabel") self._stats_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) self._sort_combo = QComboBox() self._sort_combo.setObjectName("sortCombo") self._sort_combo.addItem("文件夹+名称") self._sort_combo.addItem("名称") self._sort_combo.currentIndexChanged.connect(self._apply_sort) row.addWidget(self._select_all_cb) row.addWidget(self._stats_lbl, 1) row.addWidget(self._sort_combo) return ctrl # ── folder scanning ──────────────────────────────────────────────── def set_start_dir(self, path: str): """Set where the folder picker opens (restored last-used folder).""" self._start_dir = path or "" def _open_folder(self): folder = QFileDialog.getExistingDirectory(self, "选择文件夹", self._start_dir) if not folder: return self._start_dir = folder self.folder_opened.emit(folder) self._root_folder = Path(folder) try: self._assets = scan_image_folder(self._root_folder) except OSError as exc: logger.error("Failed to scan folder %s: %s", folder, exc) return logger.info("Loaded %d asset(s) from %s", len(self._assets), folder) self._apply_sort() self.assets_changed.emit(list(self._assets)) # Auto-preview the first image so the canvas isn't empty after loading if self._assets: self._select_preview_row(0) # ── sorting & list rebuild ───────────────────────────────────────── def _apply_sort(self): if self._sort_combo.currentIndex() == 0: # 文件夹+名称 self._assets.sort( key=lambda a: ( str(Path(a.path).parent), str(Path(a.path).name).lower(), ) ) else: # 名称 self._assets.sort(key=lambda a: str(Path(a.path).name).lower()) self._rebuild_list() def _rebuild_list(self): self._list.clear() self._current_preview_row = -1 for asset in self._assets: item = QListWidgetItem() widget = _AssetItemWidget(asset, self._root_folder) widget.check_changed.connect(self._on_check_changed) item.setSizeHint(widget.sizeHint()) self._list.addItem(item) self._list.setItemWidget(item, widget) self._refresh_stats() self._refresh_select_all() # ── interaction ──────────────────────────────────────────────────── def _on_item_clicked(self, item: QListWidgetItem): self._select_preview_row(self._list.row(item)) def _select_preview_row(self, row: int): """Highlight the row as the current preview and emit preview_changed.""" if not (0 <= row < self._list.count()): return # Clear previous preview highlight if 0 <= self._current_preview_row < self._list.count(): prev = self._list.item(self._current_preview_row) if prev: w = self._list.itemWidget(prev) if w: w.set_preview_selected(False) self._current_preview_row = row widget = self._list.itemWidget(self._list.item(row)) if widget: widget.set_preview_selected(True) self.preview_changed.emit(widget.asset) def _on_check_changed(self): self._refresh_stats() self._refresh_select_all() self.assets_changed.emit(list(self._assets)) def _on_select_all_changed(self, state: int): if state == _PARTIAL: # programmatic tristate — ignore return checked = (state == _CHECKED) for i in range(self._list.count()): w = self._list.itemWidget(self._list.item(i)) if w: w.set_checked(checked) self._refresh_stats() self.assets_changed.emit(list(self._assets)) # ── stat helpers ─────────────────────────────────────────────────── def _has_subfolders(self) -> bool: if not self._root_folder: return False return any( Path(a.path).parent != self._root_folder for a in self._assets ) def _refresh_stats(self): n = len(self._assets) sel = sum(1 for a in self._assets if a.selected) prefix = "含子文件夹 · " if self._has_subfolders() else "" self._stats_lbl.setText("{}共 {} 已选 {}".format(prefix, n, sel)) def _refresh_select_all(self): n = len(self._assets) self._select_all_cb.blockSignals(True) if n == 0: self._select_all_cb.setCheckState(Qt.Unchecked) else: sel = sum(1 for a in self._assets if a.selected) if sel == 0: self._select_all_cb.setCheckState(Qt.Unchecked) elif sel == n: self._select_all_cb.setCheckState(Qt.Checked) else: self._select_all_cb.setCheckState(Qt.PartiallyChecked) self._select_all_cb.blockSignals(False) def get_assets(self) -> List[ImageAsset]: return list(self._assets) class ImageListPanel(QWidget): """Left-panel container: garment list (top) + print list (bottom).""" garment_preview_changed = Signal(object) # ImageAsset | None print_preview_changed = Signal(object) # ImageAsset | None garments_changed = Signal(list) # List[ImageAsset] prints_changed = Signal(list) # List[ImageAsset] garment_folder_opened = Signal(str) # last-used garment folder print_folder_opened = Signal(str) # last-used print folder def __init__(self, parent=None): super().__init__(parent) self._setup_ui() def _setup_ui(self): col = QVBoxLayout(self) col.setContentsMargins(0, 0, 0, 0) col.setSpacing(0) splitter = QSplitter(Qt.Vertical) splitter.setHandleWidth(3) splitter.setObjectName("assetSplitter") self._garment_panel = _AssetPanel("衣服图片", "打开衣服文件夹") self._print_panel = _AssetPanel("印花图片", "打开印花文件夹") self._garment_panel.preview_changed.connect(self.garment_preview_changed) self._print_panel.preview_changed.connect(self.print_preview_changed) self._garment_panel.assets_changed.connect(self.garments_changed) self._print_panel.assets_changed.connect(self.prints_changed) self._garment_panel.folder_opened.connect(self.garment_folder_opened) self._print_panel.folder_opened.connect(self.print_folder_opened) splitter.addWidget(self._garment_panel) splitter.addWidget(self._print_panel) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 1) col.addWidget(splitter) self._apply_stylesheet() def _apply_stylesheet(self): self.setStyleSheet(""" #assetPanelHeader { background-color: #f7f7f7; border-bottom: 1px solid #e0e0e0; } #assetPanelTitle { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 13px; font-weight: bold; color: #1a1a1a; } #openFolderBtn { font-size: 12px; font-weight: bold; color: #ffffff; padding: 5px 12px; border: none; border-radius: 4px; background: #0078d4; } #openFolderBtn:hover { background: #106ebe; } #openFolderBtn:pressed { background: #005a9e; } #ctrlRow { background-color: #fafafa; border-bottom: 1px solid #eeeeee; } #statsLabel { font-size: 11px; color: #666666; } #sortCombo { font-size: 11px; max-width: 96px; } #assetList { background-color: #ffffff; border: none; } #assetList::item { background: transparent; border: none; } #fileNameLabel { font-size: 11px; color: #333333; } #thumbLabel { background-color: #f0f0f0; border: 1px solid #e0e0e0; border-radius: 2px; color: #aaaaaa; font-size: 16px; } #cardCheck { background-color: rgba(255, 255, 255, 0.75); border-radius: 2px; } QSplitter#assetSplitter::handle { background-color: #d6d6d6; height: 3px; } """) def get_garments(self) -> List[ImageAsset]: return self._garment_panel.get_assets() def get_prints(self) -> List[ImageAsset]: return self._print_panel.get_assets() def set_garment_start_dir(self, path: str): self._garment_panel.set_start_dir(path) def set_print_start_dir(self, path: str): self._print_panel.set_start_dir(path)