feat: implement transform panel UI with position, size, rotation controls

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 09:06:24 +08:00
co-authored by Claude Sonnet 4.6
parent 3e3e06b7a1
commit 8bb6a8e53a
2 changed files with 284 additions and 13 deletions
+273 -2
View File
@@ -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)
+11 -11
View File
@@ -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