feat: implement image list panel UI for garment and print assets
Implement ImageListPanel with two _AssetPanel sub-widgets (garment/print) separated by a vertical QSplitter: - _AssetItemWidget: checkbox (batch select) + 52px thumbnail + subfolder label + filename; WA_StyledBackground enables preview highlight (#cce4f7) - _AssetPanel: open-folder button → scan_image_folder(); stats row with tristate 全选 checkbox + "含子文件夹·共N 已选M" label + sort selector (文件夹+名称 / 名称); QListWidget with NoSelection mode so preview highlight and batch checkbox are visually distinct - ImageListPanel: exposes garment_preview_changed, print_preview_changed, garments_changed, prints_changed signals for downstream panels File scanning delegates to services/file_service.scan_image_folder(); no scanning logic duplicated in UI layer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user