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:
@@ -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):
|
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)
|
||||||
|
|||||||
@@ -348,20 +348,20 @@
|
|||||||
|
|
||||||
任务:
|
任务:
|
||||||
|
|
||||||
- [ ] 先读取 `src/app/widgets/transform_panel.py` 现有内容
|
- [x] 先读取 `src/app/widgets/transform_panel.py` 现有内容
|
||||||
- [ ] 完善 `src/app/widgets/transform_panel.py`
|
- [x] 完善 `src/app/widgets/transform_panel.py`
|
||||||
- [ ] 实现 X/Y 坐标输入
|
- [x] 实现 X/Y 坐标输入
|
||||||
- [ ] 实现宽度/高度输入
|
- [x] 实现宽度/高度输入
|
||||||
- [ ] 实现锁定宽高比例
|
- [x] 实现锁定宽高比例
|
||||||
- [ ] 实现角度输入
|
- [x] 实现角度输入
|
||||||
- [ ] 实现左旋 90 度和右旋 90 度按钮
|
- [x] 实现左旋 90 度和右旋 90 度按钮
|
||||||
- [ ] 与 `ImageCanvas` 双向同步 `TransformState`
|
- [x] 与 `ImageCanvas` 双向同步 `TransformState`
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- [ ] 参数输入后预览同步更新
|
- [x] 参数输入后预览同步更新
|
||||||
- [ ] 预览交互后参数输入框同步更新
|
- [x] 预览交互后参数输入框同步更新
|
||||||
- [ ] 程序化更新输入框不会造成信号循环
|
- [x] 程序化更新输入框不会造成信号循环
|
||||||
|
|
||||||
## 11. 模板面板 UI
|
## 11. 模板面板 UI
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user