Files
cmbot/src/app/widgets/image_list_panel.py
T

459 lines
16 KiB
Python
Raw Normal View History

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]
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")
# 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 _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)
2026-06-15 15:53:01 +08:00
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]
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;
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()