2026-06-15 18:04:29 +08:00
|
|
|
import logging
|
|
|
|
|
|
2026-06-15 15:53:01 +08:00
|
|
|
from PySide6.QtCore import Qt
|
2026-06-15 18:04:29 +08:00
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
QHBoxLayout,
|
|
|
|
|
QLabel,
|
|
|
|
|
QMainWindow,
|
|
|
|
|
QPushButton,
|
|
|
|
|
QScrollArea,
|
|
|
|
|
QSplitter,
|
|
|
|
|
QTabBar,
|
|
|
|
|
QVBoxLayout,
|
|
|
|
|
QWidget,
|
|
|
|
|
)
|
2026-06-15 15:53:01 +08:00
|
|
|
|
|
|
|
|
from version import APP_NAME, APP_VERSION
|
2026-06-16 17:01:58 +08:00
|
|
|
from core.models import BatchMode, TransformState
|
|
|
|
|
from services.config_service import load_config, save_config
|
2026-06-15 18:04:29 +08:00
|
|
|
from app.widgets.export_panel import ExportPanel
|
|
|
|
|
from app.widgets.image_canvas import ImageCanvas
|
|
|
|
|
from app.widgets.image_list_panel import ImageListPanel
|
2026-06-16 09:25:45 +08:00
|
|
|
from app.widgets.queue_panel import QueuePanel
|
2026-06-15 18:04:29 +08:00
|
|
|
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
|
|
|
|
|
_HEADER_HEIGHT = 40
|
|
|
|
|
_TABBAR_HEIGHT = 34
|
2026-06-15 15:53:01 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
|
|
|
def __init__(self):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION))
|
|
|
|
|
self.resize(1280, 720)
|
2026-06-15 18:04:29 +08:00
|
|
|
self.setMinimumSize(900, 600)
|
2026-06-15 15:53:01 +08:00
|
|
|
|
2026-06-15 18:04:29 +08:00
|
|
|
self._build_ui()
|
2026-06-15 15:53:01 +08:00
|
|
|
self.statusBar().showMessage("就绪")
|
2026-06-15 18:04:29 +08:00
|
|
|
logger.info("MainWindow initialized")
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Layout construction
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _build_ui(self):
|
2026-06-16 09:31:16 +08:00
|
|
|
# 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
|
2026-06-16 15:39:45 +08:00
|
|
|
self._current_print_path = None # keep the print across garment switches
|
2026-06-16 09:31:16 +08:00
|
|
|
|
2026-06-15 18:04:29 +08:00
|
|
|
root = QWidget()
|
|
|
|
|
root.setObjectName("root")
|
|
|
|
|
layout = QVBoxLayout(root)
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
layout.setSpacing(0)
|
|
|
|
|
|
|
|
|
|
layout.addWidget(self._create_header())
|
|
|
|
|
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()
|
2026-06-16 09:25:45 +08:00
|
|
|
self._connect_signals()
|
2026-06-16 17:01:58 +08:00
|
|
|
self._restore_preferences()
|
2026-06-15 18:04:29 +08:00
|
|
|
|
|
|
|
|
def _create_header(self):
|
|
|
|
|
"""Top header: app name, version label, settings button."""
|
|
|
|
|
header = QWidget()
|
|
|
|
|
header.setObjectName("header")
|
|
|
|
|
header.setFixedHeight(_HEADER_HEIGHT)
|
|
|
|
|
|
|
|
|
|
row = QHBoxLayout(header)
|
|
|
|
|
row.setContentsMargins(12, 0, 12, 0)
|
|
|
|
|
row.setSpacing(6)
|
|
|
|
|
|
|
|
|
|
title = QLabel(APP_NAME)
|
|
|
|
|
title.setObjectName("appTitle")
|
|
|
|
|
|
|
|
|
|
version = QLabel("v{}".format(APP_VERSION))
|
|
|
|
|
version.setObjectName("appVersion")
|
|
|
|
|
version.setAlignment(Qt.AlignVCenter)
|
|
|
|
|
|
|
|
|
|
settings_btn = QPushButton("设置")
|
|
|
|
|
settings_btn.setObjectName("settingsBtn")
|
|
|
|
|
settings_btn.setFixedWidth(60)
|
|
|
|
|
|
|
|
|
|
row.addWidget(title)
|
|
|
|
|
row.addWidget(version)
|
|
|
|
|
row.addStretch()
|
|
|
|
|
row.addWidget(settings_btn)
|
|
|
|
|
|
|
|
|
|
return header
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-16 09:25:45 +08:00
|
|
|
body_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
body_layout.setSpacing(0)
|
2026-06-15 18:04:29 +08:00
|
|
|
|
2026-06-16 09:25:45 +08:00
|
|
|
self.queue_panel = QueuePanel()
|
|
|
|
|
body_layout.addWidget(self.queue_panel)
|
2026-06-15 18:04:29 +08:00
|
|
|
|
|
|
|
|
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("▼")
|
|
|
|
|
|
2026-06-16 09:25:45 +08:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Signal wiring
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _connect_signals(self):
|
2026-06-16 09:31:16 +08:00
|
|
|
# ── Asset lists ────────────────────────────────────────────────────
|
2026-06-16 09:25:45 +08:00
|
|
|
self.image_list_panel.garments_changed.connect(self.queue_panel.set_garments)
|
|
|
|
|
self.image_list_panel.prints_changed.connect(self.queue_panel.set_prints)
|
2026-06-16 09:31:16 +08:00
|
|
|
# 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 ──────────────────────────────────────────────────────────
|
2026-06-16 09:25:45 +08:00
|
|
|
self.queue_panel.item_activated.connect(self._on_queue_item_activated)
|
|
|
|
|
|
2026-06-16 09:31:16 +08:00
|
|
|
# ── 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)
|
|
|
|
|
|
2026-06-16 17:01:58 +08:00
|
|
|
# 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"))
|
2026-06-16 15:11:56 +08:00
|
|
|
|
2026-06-16 09:31:16 +08:00
|
|
|
# ── 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.queue_panel.set_transform)
|
2026-06-16 15:39:45 +08:00
|
|
|
self.template_panel.template_applied.connect(self.export_panel.set_transform)
|
2026-06-16 09:31:16 +08:00
|
|
|
|
2026-06-16 15:39:45 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-16 09:31:16 +08:00
|
|
|
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)
|
2026-06-16 17:01:58 +08:00
|
|
|
self._update_reset_availability()
|
2026-06-16 09:31:16 +08:00
|
|
|
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
|
2026-06-16 15:39:45 +08:00
|
|
|
# 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()
|
2026-06-16 17:01:58 +08:00
|
|
|
self._update_reset_availability()
|
2026-06-16 09:31:16 +08:00
|
|
|
|
|
|
|
|
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:
|
2026-06-16 15:39:45 +08:00
|
|
|
self._current_print_path = None
|
2026-06-16 09:31:16 +08:00
|
|
|
self.export_panel.set_print(None)
|
2026-06-16 17:01:58 +08:00
|
|
|
self._update_reset_availability()
|
2026-06-16 09:31:16 +08:00
|
|
|
return
|
2026-06-16 15:39:45 +08:00
|
|
|
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()
|
2026-06-16 17:01:58 +08:00
|
|
|
self._update_reset_availability()
|
2026-06-16 09:31:16 +08:00
|
|
|
|
2026-06-16 09:25:45 +08:00
|
|
|
def _on_queue_item_activated(self, item):
|
2026-06-16 09:31:16 +08:00
|
|
|
"""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)
|
|
|
|
|
# If the item already has a fine-tuned transform, restore it
|
|
|
|
|
if item.transform:
|
|
|
|
|
self.image_canvas.set_transform(item.transform)
|
|
|
|
|
self.transform_panel.set_transform(item.transform)
|
2026-06-16 15:11:56 +08:00
|
|
|
# Keep template panel garment and print size in sync
|
2026-06-16 09:31:16 +08:00
|
|
|
try:
|
|
|
|
|
from PIL import Image as _PilImage
|
|
|
|
|
with _PilImage.open(garment_path) as img:
|
|
|
|
|
self.template_panel.set_garment_size(img.width, img.height)
|
2026-06-16 15:11:56 +08:00
|
|
|
with _PilImage.open(print_path) as img:
|
|
|
|
|
self.template_panel.set_print_size(img.width, img.height)
|
2026-06-16 09:31:16 +08:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
self._loading_queue_item = False
|
2026-06-16 17:01:58 +08:00
|
|
|
self._update_reset_availability()
|
2026-06-16 09:31:16 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-06-16 09:25:45 +08:00
|
|
|
|
2026-06-16 17:01:58 +08:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 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 _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)
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-06-15 18:04:29 +08:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Style (ref: docs/07-ui-design.md §10)
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _apply_stylesheet(self):
|
|
|
|
|
self.setStyleSheet("""
|
|
|
|
|
QMainWindow, #root {
|
|
|
|
|
background-color: #f0f0f0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Header */
|
|
|
|
|
#header {
|
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
border-bottom: 1px solid #d6d6d6;
|
|
|
|
|
}
|
|
|
|
|
#appTitle {
|
|
|
|
|
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
color: #1a1a1a;
|
|
|
|
|
}
|
|
|
|
|
#appVersion {
|
|
|
|
|
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: #888888;
|
|
|
|
|
}
|
|
|
|
|
#settingsBtn {
|
|
|
|
|
border: 1px solid #d6d6d6;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
padding: 3px 8px;
|
|
|
|
|
background: #f7f7f7;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
#settingsBtn:hover { background: #e8e8e8; }
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
}
|
|
|
|
|
""")
|