Files
cmbot/src/app/main_window.py
T

686 lines
27 KiB
Python
Raw Normal View History

import logging
import os
import threading
from pathlib import Path
from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSplitter,
QTabBar,
QVBoxLayout,
QWidget,
)
2026-06-15 15:53:01 +08:00
from version import APP_NAME, APP_VERSION
from core.models import BatchMode, TransformState
from services.config_service import load_config, save_config
from services.update_service import check_for_update
from app.widgets.export_panel import ExportPanel
from app.widgets.image_canvas import ImageCanvas
from app.widgets.image_list_panel import ImageListPanel
from app.widgets.queue_panel import QueuePanel
from app.widgets.template_panel import TemplatePanel
from app.widgets.transform_panel import TransformPanel
logger = logging.getLogger(__name__)
# Reference widths from docs/07-ui-design.md §3
_LEFT_WIDTH = 374
_RIGHT_WIDTH = 318
_QUEUE_HEIGHT = 188
_TABBAR_HEIGHT = 34
2026-06-15 15:53:01 +08:00
class MainWindow(QMainWindow):
# Emitted from the background update-check thread; delivered to the UI
# thread via Qt's queued connection so the banner is built on the main thread.
_update_found = Signal(object) # UpdateInfo
2026-06-15 15:53:01 +08:00
def __init__(self):
super().__init__()
self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION))
self.resize(1280, 720)
self.setMinimumSize(900, 600)
2026-06-15 15:53:01 +08:00
self._build_ui()
2026-06-15 15:53:01 +08:00
self.statusBar().showMessage("就绪")
logger.info("MainWindow initialized")
# ------------------------------------------------------------------
# Layout construction
# ------------------------------------------------------------------
def _build_ui(self):
# Fine-tune state: track which queue item is currently being edited
self._active_queue_item = None
self._loading_queue_item = False # suppress fine-tune marking during item load
self._current_print_path = None # keep the print across garment switches
root = QWidget()
root.setObjectName("root")
layout = QVBoxLayout(root)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Update notification banner (hidden until a newer version is found).
layout.addWidget(self._create_update_banner())
# No in-app title bar: the OS window title (setWindowTitle) already
# shows the app name and version, so an in-content header would just
# duplicate it. Start straight from the workflow tab bar.
layout.addWidget(self._create_tab_bar())
layout.addWidget(self._create_work_area(), stretch=1)
layout.addWidget(self._create_queue_panel())
self.setCentralWidget(root)
self._apply_stylesheet()
self._connect_signals()
self._restore_preferences()
self._start_update_check()
def _create_tab_bar(self):
"""Workflow step selector (QTabBar only — no swappable pane)."""
container = QWidget()
container.setObjectName("tabBarContainer")
container.setFixedHeight(_TABBAR_HEIGHT)
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._tab_bar = QTabBar()
self._tab_bar.setObjectName("flowTabBar")
self._tab_bar.addTab("1 添加印花")
self._tab_bar.addTab("2 AI 穿搭")
self._tab_bar.addTab("3 导出上架")
# Only first tab is implemented in phase 1
self._tab_bar.setTabEnabled(1, False)
self._tab_bar.setTabEnabled(2, False)
self._tab_bar.currentChanged.connect(self._on_tab_changed)
layout.addWidget(self._tab_bar)
layout.addStretch()
return container
# ------------------------------------------------------------------
# Update notification (stage 2: notify-only, see docs/10-lan-update.md)
# ------------------------------------------------------------------
def _create_update_banner(self):
"""A thin info bar shown when a newer version is found. Hidden by default."""
self._update_info = None
bar = QWidget()
bar.setObjectName("updateBanner")
bar.setVisible(False)
row = QHBoxLayout(bar)
row.setContentsMargins(12, 5, 8, 5)
row.setSpacing(8)
self._update_label = QLabel()
self._update_label.setObjectName("updateBannerText")
open_btn = QPushButton("打开更新目录")
open_btn.setObjectName("updateBannerOpen")
open_btn.setCursor(Qt.PointingHandCursor)
open_btn.clicked.connect(self._open_update_source)
close_btn = QPushButton("✕")
close_btn.setObjectName("updateBannerClose")
close_btn.setFixedWidth(24)
close_btn.setCursor(Qt.PointingHandCursor)
close_btn.setToolTip("关闭")
close_btn.clicked.connect(lambda: self._update_banner.setVisible(False))
row.addWidget(self._update_label)
row.addStretch()
row.addWidget(open_btn)
row.addWidget(close_btn)
self._update_banner = bar
return bar
def _start_update_check(self):
"""Check the configured LAN source for a newer version, off the UI thread."""
source = self._config.get("update_source", "")
if not source:
return
self._update_found.connect(self._on_update_found)
def worker():
try:
info = check_for_update(source, APP_VERSION)
except Exception: # never let the thread crash startup
logger.exception("Update check failed")
return
if info:
self._update_found.emit(info)
threading.Thread(target=worker, name="update-check", daemon=True).start()
def _on_update_found(self, info):
"""Show the update banner (runs on the UI thread via queued signal)."""
self._update_info = info
text = "发现新版本 v{},当前 v{}。".format(info.version, APP_VERSION)
if info.notes:
text += " " + info.notes
self._update_label.setText(text)
self._update_banner.setVisible(True)
def _open_update_source(self):
"""Open the update folder in the file explorer.
Prefer the version folder from the manifest; fall back to the configured
source root (which we know exists — the manifest was just read from it).
os.startfile is the reliable way to open a directory on Windows;
QDesktopServices.openUrl mishandles file:// URLs to folders (ShellExecute
error 2), so it is only a secondary fallback.
"""
if not self._update_info:
return
candidates = [
self._update_info.source,
self._config.get("update_source", ""),
]
for path in candidates:
if not path or not Path(path).exists():
continue
try:
os.startfile(path) # noqa: S606 — native Explorer open
return
except (OSError, AttributeError) as exc:
logger.warning("startfile failed for %s: %s", path, exc)
if QDesktopServices.openUrl(QUrl.fromLocalFile(path)):
return
shown = self._update_info.source or self._config.get("update_source", "")
QMessageBox.information(
self, "更新目录",
"无法自动打开更新目录,请手动前往:\n{}".format(shown),
)
def _create_work_area(self):
"""Horizontal splitter: left material panel | canvas | right params."""
splitter = QSplitter(Qt.Horizontal)
splitter.setObjectName("workSplitter")
splitter.setHandleWidth(1)
# Left: material lists (~374 px); task 8 will fill ImageListPanel
self.image_list_panel = ImageListPanel()
self.image_list_panel.setMinimumWidth(200)
splitter.addWidget(self.image_list_panel)
# Center: canvas (QGraphicsView, stretches); task 9 will expand
self.image_canvas = ImageCanvas()
self.image_canvas.setMinimumWidth(400)
splitter.addWidget(self.image_canvas)
# Right: scrollable params column (~318 px)
right_scroll = self._create_right_panel()
right_scroll.setMinimumWidth(200)
splitter.addWidget(right_scroll)
splitter.setSizes([_LEFT_WIDTH, 800, _RIGHT_WIDTH])
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setStretchFactor(2, 0)
self._work_splitter = splitter
return splitter
def _create_right_panel(self):
"""Scroll area containing template, transform and export panels."""
scroll = QScrollArea()
scroll.setObjectName("rightScroll")
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
inner = QWidget()
inner.setObjectName("rightInner")
col = QVBoxLayout(inner)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(0)
self.template_panel = TemplatePanel()
self.transform_panel = TransformPanel()
self.export_panel = ExportPanel()
col.addWidget(self.template_panel)
col.addWidget(self.transform_panel)
col.addWidget(self.export_panel)
col.addStretch()
scroll.setWidget(inner)
return scroll
def _create_queue_panel(self):
"""Bottom queue container with collapsible body."""
self._queue_container = QWidget()
self._queue_container.setObjectName("queueContainer")
self._queue_container.setFixedHeight(_QUEUE_HEIGHT)
col = QVBoxLayout(self._queue_container)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(0)
col.addWidget(self._create_queue_header())
col.addWidget(self._create_queue_body(), stretch=1)
return self._queue_container
def _create_queue_header(self):
header = QWidget()
header.setObjectName("queueHeader")
header.setFixedHeight(36)
row = QHBoxLayout(header)
row.setContentsMargins(12, 0, 12, 0)
row.setSpacing(6)
title = QLabel("合成队列")
title.setObjectName("queueTitle")
self._collapse_btn = QPushButton("▲")
self._collapse_btn.setObjectName("collapseBtn")
self._collapse_btn.setFixedWidth(28)
self._collapse_btn.clicked.connect(self._toggle_queue)
row.addWidget(title)
row.addStretch()
row.addWidget(self._collapse_btn)
return header
def _create_queue_body(self):
self._queue_body = QWidget()
self._queue_body.setObjectName("queueBody")
body_layout = QVBoxLayout(self._queue_body)
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.setSpacing(0)
self.queue_panel = QueuePanel()
body_layout.addWidget(self.queue_panel)
self._queue_expanded = True
return self._queue_body
# ------------------------------------------------------------------
# Slots
# ------------------------------------------------------------------
def _on_tab_changed(self, index):
"""Keep focus on tab 0; notify user for unimplemented tabs."""
if index != 0:
self._tab_bar.setCurrentIndex(0)
self.statusBar().showMessage("该功能暂未开放")
def _toggle_queue(self):
self._queue_expanded = not self._queue_expanded
self._queue_body.setVisible(self._queue_expanded)
if self._queue_expanded:
self._queue_container.setFixedHeight(_QUEUE_HEIGHT)
self._collapse_btn.setText("▲")
else:
self._queue_container.setFixedHeight(36)
self._collapse_btn.setText("▼")
# ------------------------------------------------------------------
# Signal wiring
# ------------------------------------------------------------------
def _connect_signals(self):
# ── Asset lists ────────────────────────────────────────────────────
self.image_list_panel.garments_changed.connect(self.queue_panel.set_garments)
self.image_list_panel.prints_changed.connect(self.queue_panel.set_prints)
# Direct preview clicks → canvas (outside of queue flow)
self.image_list_panel.garment_preview_changed.connect(self._on_garment_preview)
self.image_list_panel.print_preview_changed.connect(self._on_print_preview)
# ── Queue ──────────────────────────────────────────────────────────
self.queue_panel.item_activated.connect(self._on_queue_item_activated)
# ── Canvas ↔ TransformPanel bidirectional sync ─────────────────────
# set_transform() on either side does NOT re-emit → no signal loop
self.image_canvas.transform_changed.connect(self.transform_panel.set_transform)
self.transform_panel.transform_changed.connect(self.image_canvas.set_transform)
# Both change paths feed fine-tune detection
self.image_canvas.transform_changed.connect(self._on_transform_changed)
self.transform_panel.transform_changed.connect(self._on_transform_changed)
# Per-section reset: restore only that aspect to the selected template
self.transform_panel.reset_position.connect(lambda: self._reset_aspect("pos"))
self.transform_panel.reset_size.connect(lambda: self._reset_aspect("size"))
self.transform_panel.reset_rotation.connect(lambda: self._reset_aspect("rot"))
# Whole reset: restore position + size + rotation to the selected template
self.transform_panel.reset_all.connect(self._reset_all_to_template)
# ── Template panel ─────────────────────────────────────────────────
# Template selection updates canvas, param panel, and queue template
self.template_panel.template_applied.connect(self.image_canvas.set_transform)
self.template_panel.template_applied.connect(self.transform_panel.set_transform)
self.template_panel.template_applied.connect(self.export_panel.set_transform)
# Give the queue the template itself (ratio-based) so each item is laid
# out from its own garment/print size, not one shared pixel transform.
# template_changed fires on selection even before a garment is loaded;
# template_applied covers re-applies once sizes are known.
self.template_panel.template_changed.connect(self._sync_queue_template)
self.template_panel.template_applied.connect(self._sync_queue_template)
self.queue_panel.set_template(self.template_panel.current_template()) # initial sync
# Output settings (dir/format/quality) flow to the queue for batch export
self.export_panel.export_options_changed.connect(self.queue_panel.set_export_options)
self.queue_panel.set_export_options(self.export_panel.current_options()) # initial sync
def _load_print_preview(self, path):
"""Load a print into the canvas and sync its native size to the template panel."""
self.image_canvas.load_print(path)
self.export_panel.set_print(path)
try:
from PIL import Image as _PilImage
with _PilImage.open(path) as img:
self.template_panel.set_print_size(img.width, img.height)
except Exception:
pass
def _on_garment_preview(self, asset):
"""Load a garment from the asset list into the canvas for direct preview."""
self._active_queue_item = None # leave queue mode
if not asset:
self.export_panel.set_garment(None)
self._update_reset_availability()
return
path = str(asset.path)
self.image_canvas.load_garment(path)
self.export_panel.set_garment(path)
# Tell template panel the garment dimensions so ratio→pixel conversion works
try:
from PIL import Image as _PilImage
with _PilImage.open(path) as img:
self.template_panel.set_garment_size(img.width, img.height)
except Exception:
pass
# load_garment clears the scene; restore the current print and re-lay it
# out with the selected template (instead of the canvas default).
if self._current_print_path:
self._load_print_preview(self._current_print_path)
self.template_panel.apply_current()
self._update_reset_availability()
def _on_print_preview(self, asset):
"""Load a print from the asset list into the canvas for direct preview."""
self._active_queue_item = None
if not asset:
self._current_print_path = None
self.export_panel.set_print(None)
self._update_reset_availability()
return
self._current_print_path = str(asset.path)
self._load_print_preview(self._current_print_path)
# Apply the selected template so the print lands per the chosen layout,
# not the canvas's hard-coded centred default.
self.template_panel.apply_current()
self._update_reset_availability()
def _on_queue_item_activated(self, item):
"""Load a queue item into the canvas and arm fine-tune tracking."""
self._active_queue_item = item
self._loading_queue_item = True
try:
garment_path = str(item.garment.path)
print_path = str(item.print_asset.path)
self.image_canvas.load_garment(garment_path)
self.image_canvas.load_print(print_path)
self.export_panel.set_garment(garment_path)
self.export_panel.set_print(print_path)
# Keep template panel garment and print size in sync first, so
# apply_current() below can compute the template layout.
try:
from PIL import Image as _PilImage
with _PilImage.open(garment_path) as img:
self.template_panel.set_garment_size(img.width, img.height)
with _PilImage.open(print_path) as img:
self.template_panel.set_print_size(img.width, img.height)
except Exception:
pass
# Fine-tuned item: restore its own transform. Otherwise lay the print
# out per the selected template instead of load_print's default.
if item.transform:
self.image_canvas.set_transform(item.transform)
self.transform_panel.set_transform(item.transform)
else:
self.template_panel.apply_current()
finally:
self._loading_queue_item = False
self._update_reset_availability()
def _sync_queue_template(self, _state=None):
"""Push the currently selected template (ratio-based) to the queue so
batch export recomputes each item from its own garment/print size."""
self.queue_panel.set_template(self.template_panel.current_template())
def _on_transform_changed(self, state):
"""Route transform changes to panels and mark the active queue item fine-tuned."""
# Keep template panel and export panel up to date for save/export actions
self.template_panel.set_transform(state)
self.export_panel.set_transform(state)
# Mark the active queue item as fine-tuned (suppressed during item load)
if self._loading_queue_item or self._active_queue_item is None:
return
self.queue_panel.mark_item_fine_tuned(self._active_queue_item, state)
# ------------------------------------------------------------------
# Per-aspect reset (position / size / rotation → selected template)
# ------------------------------------------------------------------
def _update_reset_availability(self):
"""Enable the section resets only when a print is on the canvas."""
has_print = self.image_canvas.get_transform() is not None
self.transform_panel.set_resets_enabled(has_print)
def _reset_aspect(self, aspect):
"""Restore only one aspect of the current transform to the selected template."""
tpl = self.template_panel.current_template_state()
cur = self.image_canvas.get_transform()
if tpl is None or cur is None:
return
new = TransformState(
x=cur.x, y=cur.y, width=cur.width, height=cur.height,
rotation=cur.rotation, keep_aspect_ratio=cur.keep_aspect_ratio,
)
if aspect == "pos":
# Centre the current-sized print on the template's centre
new.x = (tpl.x + tpl.width / 2.0) - cur.width / 2.0
new.y = (tpl.y + tpl.height / 2.0) - cur.height / 2.0
elif aspect == "size":
# Only the size; leave position (x/y) untouched so each reset is independent
new.width, new.height = tpl.width, tpl.height
elif aspect == "rot":
new.rotation = tpl.rotation
self._apply_preview_transform(new)
def _reset_all_to_template(self):
"""Restore position + size + rotation to the selected template at once."""
tpl = self.template_panel.current_template_state()
cur = self.image_canvas.get_transform()
if tpl is None or cur is None:
return
new = TransformState(
x=tpl.x, y=tpl.y, width=tpl.width, height=tpl.height,
rotation=tpl.rotation, keep_aspect_ratio=cur.keep_aspect_ratio,
)
self._apply_preview_transform(new)
def _apply_preview_transform(self, state):
"""Push a transform to the canvas + panels as if it were a user edit."""
self.image_canvas.set_transform(state)
self.transform_panel.set_transform(state)
self._on_transform_changed(state)
# ------------------------------------------------------------------
# Preference persistence (last template / last batch mode)
# ------------------------------------------------------------------
def _restore_preferences(self):
"""Load saved preferences, apply them, then start persisting changes."""
self._config = load_config()
name = self._config.get("last_template", "")
if name:
self.template_panel.select_template(name)
raw_mode = self._config.get("last_batch_mode", "")
try:
self.queue_panel.set_batch_mode(BatchMode(raw_mode))
except ValueError:
logger.warning("Unknown last_batch_mode %r, keeping default", raw_mode)
# Restore where the folder pickers open (last-used folders)
self.image_list_panel.set_garment_start_dir(self._config.get("last_garment_dir", ""))
self.image_list_panel.set_print_start_dir(self._config.get("last_print_dir", ""))
# Persist future changes (connected after restore to avoid echo writes)
self.template_panel.template_changed.connect(self._on_template_persist)
self.queue_panel.batch_mode_changed.connect(self._on_batch_mode_persist)
self.image_list_panel.garment_folder_opened.connect(self._on_garment_dir_persist)
self.image_list_panel.print_folder_opened.connect(self._on_print_dir_persist)
def _on_template_persist(self, name):
self._config["last_template"] = name
save_config(self._config)
def _on_batch_mode_persist(self, mode):
self._config["last_batch_mode"] = getattr(mode, "value", str(mode))
save_config(self._config)
def _on_garment_dir_persist(self, path):
self._config["last_garment_dir"] = path
save_config(self._config)
def _on_print_dir_persist(self, path):
self._config["last_print_dir"] = path
save_config(self._config)
# ------------------------------------------------------------------
# Style (ref: docs/07-ui-design.md §10)
# ------------------------------------------------------------------
def _apply_stylesheet(self):
self.setStyleSheet("""
QMainWindow, #root {
background-color: #f0f0f0;
}
/* Update notification banner */
#updateBanner {
background-color: #eef6ff;
border-bottom: 1px solid #cfe3fa;
}
#updateBannerText {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 12px;
color: #1b4f86;
}
#updateBannerOpen {
font-size: 12px;
padding: 3px 12px;
border: 1px solid #0078d4;
border-radius: 3px;
color: #0078d4;
background: transparent;
}
#updateBannerOpen:hover { background: #d8e9fb; }
#updateBannerClose {
font-size: 12px;
border: none;
color: #6a8bab;
background: transparent;
}
#updateBannerClose:hover { color: #1b4f86; }
/* Flow tab bar */
#tabBarContainer {
background-color: #f0f0f0;
border-bottom: 1px solid #d6d6d6;
}
QTabBar#flowTabBar::tab {
padding: 6px 28px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #555555;
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
}
QTabBar#flowTabBar::tab:selected {
color: #0078d4;
border-bottom: 2px solid #0078d4;
}
QTabBar#flowTabBar::tab:!selected:!disabled:hover {
color: #0078d4;
background: #e8f0fb;
}
QTabBar#flowTabBar::tab:disabled {
color: #bbbbbb;
}
/* Work splitter */
QSplitter#workSplitter::handle {
background-color: #d6d6d6;
width: 1px;
}
/* Right scroll */
#rightScroll {
border: none;
background-color: #f7f7f7;
}
#rightInner {
background-color: #f7f7f7;
}
/* Queue */
#queueContainer {
background-color: #ffffff;
border-top: 1px solid #d6d6d6;
}
#queueHeader {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d6d6;
}
#queueTitle {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
font-weight: bold;
color: #1a1a1a;
}
#collapseBtn {
border: none;
background: transparent;
color: #555555;
font-size: 10px;
}
#collapseBtn:hover { color: #0078d4; }
#queueBody { background-color: #ffffff; }
#queuePlaceholder {
color: #aaaaaa;
font-size: 12px;
}
/* Status bar */
QStatusBar {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 12px;
background-color: #f0f0f0;
border-top: 1px solid #d6d6d6;
}
""")