import logging from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QMainWindow, QPushButton, QScrollArea, QSplitter, QTabBar, QVBoxLayout, QWidget, ) from version import APP_NAME, APP_VERSION from core.models import BatchMode, TransformState from services.config_service import load_config, save_config 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 class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION)) self.resize(1280, 720) self.setMinimumSize(900, 600) self._build_ui() 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) # 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() 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) 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.queue_panel.set_transform) self.template_panel.template_applied.connect(self.export_panel.set_transform) # 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 _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; } /* 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; } """)