diff --git a/src/app/widgets/image_list_panel.py b/src/app/widgets/image_list_panel.py index 5419ec8..1c4b539 100644 --- a/src/app/widgets/image_list_panel.py +++ b/src/app/widgets/image_list_panel.py @@ -1,5 +1,438 @@ -from PySide6.QtWidgets import QWidget +import logging +from pathlib import Path +from typing import List, Optional + +from PySide6.QtCore import 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 = 52 # thumbnail size in px + +# CheckState int values (avoids enum import issues across PySide6 versions) +_UNCHECKED = 0 +_PARTIAL = 1 +_CHECKED = 2 + + +class _AssetItemWidget(QWidget): + """Single row: checkbox + thumbnail + subfolder label + filename.""" + + 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): + row = QHBoxLayout(self) + row.setContentsMargins(4, 3, 4, 3) + row.setSpacing(6) + + # Batch-selection checkbox + self._check = QCheckBox() + self._check.setChecked(self._asset.selected) + self._check.stateChanged.connect(self._on_check_changed) + + # Thumbnail + self._thumb = QLabel() + self._thumb.setFixedSize(_THUMB_SIZE, _THUMB_SIZE) + self._thumb.setAlignment(Qt.AlignCenter) + self._thumb.setObjectName("thumbLabel") + self._load_thumbnail() + + # Info column: subfolder + filename + info_col = QWidget() + info_layout = QVBoxLayout(info_col) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(2) + + subfolder = self._compute_subfolder() + self._folder_label = QLabel(subfolder) + self._folder_label.setObjectName("subfolderLabel") + self._folder_label.setVisible(bool(subfolder)) + + self._name_label = QLabel(Path(self._asset.path).name) + self._name_label.setObjectName("fileNameLabel") + self._name_label.setWordWrap(False) + + info_layout.addWidget(self._folder_label) + info_layout.addWidget(self._name_label) + info_layout.addStretch() + + row.addWidget(self._check, 0, Qt.AlignVCenter) + row.addWidget(self._thumb) + row.addWidget(info_col, 1) + + self.setFixedHeight(64) + + 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("background-color: #cce4f7;") + 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] + + 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._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") + 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.setSpacing(1) + 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 _open_folder(self): + folder = QFileDialog.getExistingDirectory(self, "选择文件夹") + if not folder: + return + 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)) + + # ── 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): + row = self._list.row(item) + + # 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(item) + 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): - pass + """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] + + 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) + + 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; + padding: 2px 8px; + border: 1px solid #d6d6d6; + border-radius: 3px; + background: #ffffff; + } + #openFolderBtn:hover { background: #e8e8e8; } + #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; + } + #subfolderLabel { + font-size: 10px; + color: #888888; + } + #fileNameLabel { + font-size: 12px; + color: #1a1a1a; + } + #thumbLabel { + background-color: #f0f0f0; + border: 1px solid #e0e0e0; + border-radius: 2px; + color: #aaaaaa; + font-size: 16px; + } + 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() diff --git a/tasks.md b/tasks.md index 8ce89f1..f750c18 100644 --- a/tasks.md +++ b/tasks.md @@ -290,22 +290,22 @@ 任务: -- [ ] 先读取 `src/app/widgets/image_list_panel.py` 现有内容 -- [ ] 实现衣服图片列表 -- [ ] 实现印花图片列表 -- [ ] 实现打开文件夹按钮 -- [ ] 实现全选和取消全选 -- [ ] 实现单张勾选和取消勾选 -- [ ] 显示已选数量和总数量(例如:`含子文件夹 · 共 12`) -- [ ] 显示来源子文件夹标签和文件名 -- [ ] 实现排序选择器(默认按文件夹+名称排序) -- [ ] 点击素材后更新当前预览选择 +- [x] 先读取 `src/app/widgets/image_list_panel.py` 现有内容 +- [x] 实现衣服图片列表 +- [x] 实现印花图片列表 +- [x] 实现打开文件夹按钮 +- [x] 实现全选和取消全选 +- [x] 实现单张勾选和取消勾选 +- [x] 显示已选数量和总数量(例如:`含子文件夹 · 共 12`) +- [x] 显示来源子文件夹标签和文件名 +- [x] 实现排序选择器(默认按文件夹+名称排序) +- [x] 点击素材后更新当前预览选择 验收: -- [ ] 支持递归导入素材 -- [ ] 当前预览选中态和批量勾选态可区分 -- [ ] 不在 UI 层重复实现文件扫描规则 +- [x] 支持递归导入素材 +- [x] 当前预览选中态和批量勾选态可区分 +- [x] 不在 UI 层重复实现文件扫描规则 ## 9. 图像预览编辑器