Files
cmbot/src/app/widgets/image_list_panel.py
T
adminandClaude Opus 4.8 edcd5d1e18 feat: auto-preview, timestamped/grouped output, folder memory; fix batch format
- Auto-preview the first image after a folder loads (both panels), so the
  canvas isn't empty (§17.8).
- Group exports as <output_dir>/<timestamp>/<print_stem>/<garment_stem>.<ext>;
  one timestamp per export run, shared across the batch (§17.9).
- Remember the last garment/print folders and reopen the picker there;
  persisted via config, wired through main_window (§17.10).
- Shrink the 3 box templates further and lower them (chest placement).
- Fix: batch export ignored the chosen format/quality/output dir because the
  queue never received ExportOptions. Export panel now emits
  export_options_changed → queue.set_export_options (+ initial sync) (§17.11).

Docs: PRD 6.1/6.8/9, architecture 4.12, UI design 5.1, tasks.md 17.8–17.11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:13:01 +08:00

485 lines
17 KiB
Python

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)