Add left node-library dock (grouped, searchable, drag-and-drop vector symbols), right property dock with type-aware grouped editors (incl. geometry, catalogue single/multi, color, value list), zoom/pan/fit canvas view accepting node drops, flow animator, configuration dialog, and the MainWindow wiring menus/toolbar, undo/redo, delete/duplicate, calculate flow, save/open (JSON), and PNG/SVG export. Includes a demo sample network, entry points, and panel + app integration tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
6.2 KiB
Python
162 lines
6.2 KiB
Python
"""Per-type property editor widgets used by the property panel."""
|
|
from __future__ import annotations
|
|
|
|
from PyQt6.QtCore import pyqtSignal
|
|
from PyQt6.QtGui import QColor
|
|
from PyQt6.QtWidgets import (
|
|
QCheckBox, QColorDialog, QComboBox, QDoubleSpinBox, QHBoxLayout, QLineEdit,
|
|
QListWidget, QListWidgetItem, QPushButton, QSpinBox, QWidget,
|
|
)
|
|
from PyQt6.QtCore import Qt
|
|
|
|
from ..model.catalogue import CatalogueRegistry
|
|
from ..model.properties import Property, PropertyType
|
|
|
|
|
|
class PropertyEditor(QWidget):
|
|
"""A single labelled control bound to a :class:`Property`.
|
|
|
|
Emits :attr:`value_changed` with the new (coerced) value on user edits.
|
|
"""
|
|
|
|
value_changed = pyqtSignal(object)
|
|
|
|
def __init__(self, prop: Property, catalogues: CatalogueRegistry, parent=None):
|
|
super().__init__(parent)
|
|
self.prop = prop
|
|
self.catalogues = catalogues
|
|
self._control: QWidget
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
self._build()
|
|
layout.addWidget(self._control)
|
|
self.setEnabled(prop.editable)
|
|
|
|
# -- construction -----------------------------------------------------
|
|
def _build(self) -> None:
|
|
t = self.prop.ptype
|
|
v = self.prop.value
|
|
if t == PropertyType.STRING:
|
|
w = QLineEdit(str(v or ""))
|
|
w.editingFinished.connect(lambda: self.value_changed.emit(w.text()))
|
|
self._control = w
|
|
elif t == PropertyType.INT:
|
|
w = QSpinBox()
|
|
w.setRange(int(self.prop.minimum) if self.prop.minimum is not None else -10**6,
|
|
int(self.prop.maximum) if self.prop.maximum is not None else 10**6)
|
|
w.setValue(int(v or 0))
|
|
if self.prop.unit:
|
|
w.setSuffix(f" {self.prop.unit}")
|
|
w.valueChanged.connect(lambda val: self.value_changed.emit(val))
|
|
self._control = w
|
|
elif t == PropertyType.FLOAT:
|
|
w = QDoubleSpinBox()
|
|
w.setDecimals(3)
|
|
w.setRange(self.prop.minimum if self.prop.minimum is not None else -1e9,
|
|
self.prop.maximum if self.prop.maximum is not None else 1e9)
|
|
w.setValue(float(v or 0.0))
|
|
if self.prop.unit:
|
|
w.setSuffix(f" {self.prop.unit}")
|
|
w.valueChanged.connect(lambda val: self.value_changed.emit(val))
|
|
self._control = w
|
|
elif t == PropertyType.BOOL:
|
|
w = QCheckBox()
|
|
w.setChecked(bool(v))
|
|
w.toggled.connect(lambda val: self.value_changed.emit(val))
|
|
self._control = w
|
|
elif t == PropertyType.ENUM:
|
|
w = QComboBox()
|
|
w.addItems([str(o) for o in self.prop.options])
|
|
if v is not None and str(v) in [str(o) for o in self.prop.options]:
|
|
w.setCurrentText(str(v))
|
|
w.currentTextChanged.connect(lambda val: self.value_changed.emit(val))
|
|
self._control = w
|
|
elif t == PropertyType.COLOR:
|
|
self._control = self._build_color(v)
|
|
elif t == PropertyType.CATALOGUE_ITEM:
|
|
self._control = self._build_catalogue_single(v)
|
|
elif t == PropertyType.CATALOGUE_ITEMS:
|
|
self._control = self._build_catalogue_multi(v)
|
|
elif t == PropertyType.VALUE_LIST:
|
|
w = QLineEdit(", ".join(str(x) for x in (v or [])))
|
|
w.setPlaceholderText("comma-separated values")
|
|
w.editingFinished.connect(
|
|
lambda: self.value_changed.emit(self._parse_list(w.text())))
|
|
self._control = w
|
|
else: # pragma: no cover - defensive
|
|
self._control = QLineEdit(str(v))
|
|
|
|
def _build_color(self, value) -> QWidget:
|
|
btn = QPushButton(str(value or "#ffffff"))
|
|
self._color = str(value or "#ffffff")
|
|
|
|
def paint():
|
|
c = QColor(self._color)
|
|
fg = "#000000" if c.lightnessF() > 0.5 else "#ffffff"
|
|
btn.setStyleSheet(f"background:{self._color}; color:{fg};")
|
|
btn.setText(self._color)
|
|
|
|
def pick():
|
|
c = QColorDialog.getColor(QColor(self._color))
|
|
if c.isValid():
|
|
self._color = c.name()
|
|
paint()
|
|
self.value_changed.emit(self._color)
|
|
|
|
btn.clicked.connect(pick)
|
|
paint()
|
|
return btn
|
|
|
|
def _build_catalogue_single(self, value) -> QWidget:
|
|
w = QComboBox()
|
|
cat = self.catalogues.get(self.prop.catalogue) if self.prop.catalogue else None
|
|
self._ids: list[str] = []
|
|
if cat is not None:
|
|
for item in cat:
|
|
w.addItem(item.name, item.item_id)
|
|
self._ids.append(item.item_id)
|
|
if value in self._ids:
|
|
w.setCurrentIndex(self._ids.index(value))
|
|
w.currentIndexChanged.connect(
|
|
lambda idx: self.value_changed.emit(w.itemData(idx)))
|
|
return w
|
|
|
|
def _build_catalogue_multi(self, value) -> QWidget:
|
|
w = QListWidget()
|
|
w.setMaximumHeight(90)
|
|
selected = set(value or [])
|
|
cat = self.catalogues.get(self.prop.catalogue) if self.prop.catalogue else None
|
|
if cat is not None:
|
|
for item in cat:
|
|
it = QListWidgetItem(item.name)
|
|
it.setData(Qt.ItemDataRole.UserRole, item.item_id)
|
|
it.setFlags(it.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
it.setCheckState(Qt.CheckState.Checked if item.item_id in selected
|
|
else Qt.CheckState.Unchecked)
|
|
w.addItem(it)
|
|
|
|
def changed(_item):
|
|
ids = [w.item(i).data(Qt.ItemDataRole.UserRole)
|
|
for i in range(w.count())
|
|
if w.item(i).checkState() == Qt.CheckState.Checked]
|
|
self.value_changed.emit(ids)
|
|
|
|
w.itemChanged.connect(changed)
|
|
return w
|
|
|
|
@staticmethod
|
|
def _parse_list(text: str) -> list:
|
|
out = []
|
|
for token in text.split(","):
|
|
token = token.strip()
|
|
if not token:
|
|
continue
|
|
try:
|
|
out.append(int(token))
|
|
except ValueError:
|
|
try:
|
|
out.append(float(token))
|
|
except ValueError:
|
|
out.append(token)
|
|
return out
|