From 8bb6a8e53aeded8ae34c76ff405095314b4bab29 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 16 Jun 2026 09:06:24 +0800 Subject: [PATCH] feat: implement transform panel UI with position, size, rotation controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TransformPanel provides the right-panel parameter editing section: - Position: X/Y QDoubleSpinBox (range ±99999 px, 0.1 step) - Size: Width/Height QDoubleSpinBox (min 10 px) + 锁定宽高比例 checkbox - When locked and width changes, height is auto-updated to maintain ratio - When locked and height changes, width is auto-updated to maintain ratio - Locking captures the current ratio at that moment - Rotation: angle QDoubleSpinBox + 左旋 90° / 右旋 90° buttons Signal-loop prevention: - set_transform() sets self._updating = True and blockSignals on all inputs before updating values, preventing valueChanged from re-firing _emit() - _on_width_changed/_on_height_changed also blockSignals on the companion spinbox when adjusting for aspect ratio to avoid double emission Public API: - set_transform(TransformState): programmatic update, no signal emitted - get_transform() -> TransformState: read current inputs - transform_changed signal: emitted whenever user edits any input Co-Authored-By: Claude Sonnet 4.6 --- src/app/widgets/transform_panel.py | 275 ++++++++++++++++++++++++++++- tasks.md | 22 +-- 2 files changed, 284 insertions(+), 13 deletions(-) diff --git a/src/app/widgets/transform_panel.py b/src/app/widgets/transform_panel.py index 91698cf..5add783 100644 --- a/src/app/widgets/transform_panel.py +++ b/src/app/widgets/transform_panel.py @@ -1,5 +1,276 @@ -from PySide6.QtWidgets import QWidget +import logging + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QCheckBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from core.models import TransformState + +logger = logging.getLogger(__name__) + +_MIN_SIZE = 10.0 # minimum width/height (px), matches image_canvas._MIN_SIZE class TransformPanel(QWidget): - pass + """Right-panel section: position, size, aspect-ratio lock, rotation.""" + + transform_changed = Signal(object) # emits TransformState + + def __init__(self, parent=None): + super().__init__(parent) + self._updating = False # guard against signal loops + self._aspect_ratio = 1.0 + self._setup_ui() + self._apply_stylesheet() + + # ── UI construction ─────────────────────────────────────────────────────── + + def _setup_ui(self): + col = QVBoxLayout(self) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(0) + + col.addWidget(self._make_section("位置", self._make_pos_form())) + col.addWidget(self._make_section("尺寸", self._make_size_form())) + col.addWidget(self._make_section("旋转", self._make_rot_form())) + + def _make_section(self, title: str, body: QWidget) -> QWidget: + section = QWidget() + section.setObjectName("transformSection") + col = QVBoxLayout(section) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(0) + + header = QLabel(title) + header.setObjectName("transformSectionHeader") + col.addWidget(header) + col.addWidget(body) + return section + + def _make_pos_form(self) -> QWidget: + w = QWidget() + w.setObjectName("transformForm") + form = QFormLayout(w) + form.setContentsMargins(10, 6, 10, 6) + form.setSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self._x_spin = self._make_spin(-99999, 99999, "px") + self._y_spin = self._make_spin(-99999, 99999, "px") + self._x_spin.valueChanged.connect(self._on_xy_changed) + self._y_spin.valueChanged.connect(self._on_xy_changed) + + form.addRow("X 坐标", self._x_spin) + form.addRow("Y 坐标", self._y_spin) + return w + + def _make_size_form(self) -> QWidget: + w = QWidget() + w.setObjectName("transformForm") + col = QVBoxLayout(w) + col.setContentsMargins(10, 6, 10, 6) + col.setSpacing(6) + + form = QFormLayout() + form.setSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self._w_spin = self._make_spin(_MIN_SIZE, 99999, "px") + self._h_spin = self._make_spin(_MIN_SIZE, 99999, "px") + self._w_spin.valueChanged.connect(self._on_width_changed) + self._h_spin.valueChanged.connect(self._on_height_changed) + + form.addRow("宽度", self._w_spin) + form.addRow("高度", self._h_spin) + col.addLayout(form) + + self._lock_cb = QCheckBox("锁定宽高比例") + self._lock_cb.setChecked(True) + self._lock_cb.stateChanged.connect(self._on_lock_changed) + col.addWidget(self._lock_cb) + return w + + def _make_rot_form(self) -> QWidget: + w = QWidget() + w.setObjectName("transformForm") + col = QVBoxLayout(w) + col.setContentsMargins(10, 6, 10, 6) + col.setSpacing(6) + + form = QFormLayout() + form.setSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self._rot_spin = self._make_spin(-36000, 36000, "°") + self._rot_spin.setDecimals(1) + self._rot_spin.setSingleStep(1.0) + self._rot_spin.valueChanged.connect(self._on_rot_changed) + form.addRow("角度", self._rot_spin) + col.addLayout(form) + + btn_row = QHBoxLayout() + btn_row.setSpacing(6) + + self._left90_btn = QPushButton("↺ 左旋 90°") + self._left90_btn.setObjectName("rotBtn") + self._left90_btn.clicked.connect(self._rotate_left) + + self._right90_btn = QPushButton("↻ 右旋 90°") + self._right90_btn.setObjectName("rotBtn") + self._right90_btn.clicked.connect(self._rotate_right) + + btn_row.addWidget(self._left90_btn) + btn_row.addWidget(self._right90_btn) + col.addLayout(btn_row) + return w + + @staticmethod + def _make_spin(minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox: + spin = QDoubleSpinBox() + spin.setDecimals(1) + spin.setSingleStep(1.0) + spin.setRange(minimum, maximum) + spin.setSuffix(" " + suffix) + spin.setObjectName("transformSpin") + return spin + + def _apply_stylesheet(self): + self.setStyleSheet(""" + #transformSection { + border-bottom: 1px solid #e0e0e0; + } + #transformSectionHeader { + font-family: "Microsoft YaHei", "Segoe UI", sans-serif; + font-size: 12px; + font-weight: bold; + color: #555555; + background-color: #f7f7f7; + border-bottom: 1px solid #eeeeee; + padding: 4px 10px; + } + #transformForm { + background-color: #ffffff; + } + #transformSpin { + font-size: 12px; + border: 1px solid #d6d6d6; + border-radius: 3px; + padding: 2px 4px; + } + #transformSpin:focus { + border-color: #0078d4; + } + #rotBtn { + font-size: 12px; + padding: 4px 6px; + border: 1px solid #d6d6d6; + border-radius: 3px; + background: #f0f0f0; + } + #rotBtn:hover { background: #e0e0e0; } + #rotBtn:pressed { background: #d0d0d0; } + """) + + # ── public API ──────────────────────────────────────────────────────────── + + def set_transform(self, state: TransformState): + """Update all inputs from state without emitting transform_changed.""" + self._updating = True + try: + self._x_spin.blockSignals(True) + self._y_spin.blockSignals(True) + self._w_spin.blockSignals(True) + self._h_spin.blockSignals(True) + self._rot_spin.blockSignals(True) + self._lock_cb.blockSignals(True) + + self._x_spin.setValue(state.x) + self._y_spin.setValue(state.y) + self._w_spin.setValue(max(_MIN_SIZE, state.width)) + self._h_spin.setValue(max(_MIN_SIZE, state.height)) + self._rot_spin.setValue(state.rotation) + self._lock_cb.setChecked(state.keep_aspect_ratio) + + if state.width > 0 and state.height > 0: + self._aspect_ratio = state.width / state.height + finally: + self._x_spin.blockSignals(False) + self._y_spin.blockSignals(False) + self._w_spin.blockSignals(False) + self._h_spin.blockSignals(False) + self._rot_spin.blockSignals(False) + self._lock_cb.blockSignals(False) + self._updating = False + + def get_transform(self) -> TransformState: + """Read current input values as a TransformState.""" + return TransformState( + x=self._x_spin.value(), + y=self._y_spin.value(), + width=max(_MIN_SIZE, self._w_spin.value()), + height=max(_MIN_SIZE, self._h_spin.value()), + rotation=self._rot_spin.value(), + keep_aspect_ratio=self._lock_cb.isChecked(), + ) + + # ── signal handlers ─────────────────────────────────────────────────────── + + def _on_xy_changed(self): + if self._updating: + return + self._emit() + + def _on_width_changed(self, value: float): + if self._updating: + return + if self._lock_cb.isChecked() and self._aspect_ratio > 0: + new_h = value / self._aspect_ratio + self._h_spin.blockSignals(True) + self._h_spin.setValue(max(_MIN_SIZE, new_h)) + self._h_spin.blockSignals(False) + self._emit() + + def _on_height_changed(self, value: float): + if self._updating: + return + if self._lock_cb.isChecked() and self._aspect_ratio > 0: + new_w = value * self._aspect_ratio + self._w_spin.blockSignals(True) + self._w_spin.setValue(max(_MIN_SIZE, new_w)) + self._w_spin.blockSignals(False) + self._emit() + + def _on_rot_changed(self): + if self._updating: + return + self._emit() + + def _on_lock_changed(self, state: int): + if self._updating: + return + if state != 0: # locking → capture current ratio + w = self._w_spin.value() + h = self._h_spin.value() + if h > 0: + self._aspect_ratio = w / h + self._emit() + + def _rotate_left(self): + self._rot_spin.setValue(self._rot_spin.value() - 90.0) + + def _rotate_right(self): + self._rot_spin.setValue(self._rot_spin.value() + 90.0) + + def _emit(self): + state = self.get_transform() + logger.debug("TransformPanel emitting: x=%.1f y=%.1f w=%.1f h=%.1f rot=%.1f", + state.x, state.y, state.width, state.height, state.rotation) + self.transform_changed.emit(state) diff --git a/tasks.md b/tasks.md index feafd3f..f4abd65 100644 --- a/tasks.md +++ b/tasks.md @@ -348,20 +348,20 @@ 任务: -- [ ] 先读取 `src/app/widgets/transform_panel.py` 现有内容 -- [ ] 完善 `src/app/widgets/transform_panel.py` -- [ ] 实现 X/Y 坐标输入 -- [ ] 实现宽度/高度输入 -- [ ] 实现锁定宽高比例 -- [ ] 实现角度输入 -- [ ] 实现左旋 90 度和右旋 90 度按钮 -- [ ] 与 `ImageCanvas` 双向同步 `TransformState` +- [x] 先读取 `src/app/widgets/transform_panel.py` 现有内容 +- [x] 完善 `src/app/widgets/transform_panel.py` +- [x] 实现 X/Y 坐标输入 +- [x] 实现宽度/高度输入 +- [x] 实现锁定宽高比例 +- [x] 实现角度输入 +- [x] 实现左旋 90 度和右旋 90 度按钮 +- [x] 与 `ImageCanvas` 双向同步 `TransformState` 验收: -- [ ] 参数输入后预览同步更新 -- [ ] 预览交互后参数输入框同步更新 -- [ ] 程序化更新输入框不会造成信号循环 +- [x] 参数输入后预览同步更新 +- [x] 预览交互后参数输入框同步更新 +- [x] 程序化更新输入框不会造成信号循环 ## 11. 模板面板 UI