Ilya ae3f752b6f feat(ui): panels, canvas view, animation, and main window
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>
2026-07-02 23:25:14 +02:00

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