Add UI-agnostic model layer: typed grouped properties (string/int/float/ bool/enum/color/catalogue item(s)/value list), catalogue registry, port relations with color schemes and compatibility rules, ports, node/edge models, and DiagramDocument with change signals and JSON round-trip. Include a starter node library (source/tank/pump/valve/junction/consumer/ transformer) with matching SVG symbols, plus model+document tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
7.3 KiB
Python
229 lines
7.3 KiB
Python
"""Typed, groupable properties for nodes and edges.
|
|
|
|
The model layer is intentionally UI-agnostic (no Qt imports) so it can be unit
|
|
tested without a running QApplication. Colors are stored as ``#rrggbb`` strings
|
|
and converted to ``QColor`` only in the view layer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Any, Callable, Iterable, Iterator, Optional
|
|
|
|
|
|
class PropertyType(str, Enum):
|
|
"""Supported property value types."""
|
|
|
|
STRING = "string"
|
|
INT = "int"
|
|
FLOAT = "float"
|
|
BOOL = "bool"
|
|
ENUM = "enum"
|
|
COLOR = "color"
|
|
CATALOGUE_ITEM = "catalogue_item" # single reference into a catalogue
|
|
CATALOGUE_ITEMS = "catalogue_items" # multiple references into a catalogue
|
|
VALUE_LIST = "value_list" # free list of scalar values
|
|
|
|
|
|
# Coercion helpers keep stored values well-typed after edits/deserialization.
|
|
def _coerce(ptype: "PropertyType", value: Any) -> Any:
|
|
if value is None:
|
|
return None
|
|
if ptype is PropertyType.INT:
|
|
return int(value)
|
|
if ptype is PropertyType.FLOAT:
|
|
return float(value)
|
|
if ptype is PropertyType.BOOL:
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
return bool(value)
|
|
if ptype in (PropertyType.CATALOGUE_ITEMS, PropertyType.VALUE_LIST):
|
|
return list(value)
|
|
return value
|
|
|
|
|
|
class Property:
|
|
"""A single named, typed property value.
|
|
|
|
Attributes:
|
|
key: stable machine identifier (unique within its group).
|
|
label: human-readable name.
|
|
ptype: :class:`PropertyType`.
|
|
value: current value (type depends on ``ptype``).
|
|
options: allowed values for ENUM types.
|
|
catalogue: catalogue name for CATALOGUE_ITEM(S) types.
|
|
unit: optional unit suffix shown in the editor (e.g. "mm", "m3/h").
|
|
editable: if False the editor renders read-only.
|
|
minimum/maximum: optional numeric bounds.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"key", "label", "ptype", "value", "options", "catalogue",
|
|
"unit", "editable", "minimum", "maximum", "on_change",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
key: str,
|
|
label: str,
|
|
ptype: PropertyType,
|
|
value: Any = None,
|
|
*,
|
|
options: Optional[Iterable[str]] = None,
|
|
catalogue: Optional[str] = None,
|
|
unit: str = "",
|
|
editable: bool = True,
|
|
minimum: Optional[float] = None,
|
|
maximum: Optional[float] = None,
|
|
) -> None:
|
|
self.key = key
|
|
self.label = label
|
|
self.ptype = PropertyType(ptype)
|
|
self.options = list(options) if options else []
|
|
self.catalogue = catalogue
|
|
self.unit = unit
|
|
self.editable = editable
|
|
self.minimum = minimum
|
|
self.maximum = maximum
|
|
self.on_change: Optional[Callable[["Property"], None]] = None
|
|
self.value = _coerce(self.ptype, value)
|
|
|
|
def set_value(self, value: Any) -> bool:
|
|
"""Set a coerced, bounds-clamped value. Returns True if it changed."""
|
|
new = _coerce(self.ptype, value)
|
|
if self.ptype in (PropertyType.INT, PropertyType.FLOAT) and new is not None:
|
|
if self.minimum is not None:
|
|
new = max(new, self.minimum)
|
|
if self.maximum is not None:
|
|
new = min(new, self.maximum)
|
|
if new == self.value:
|
|
return False
|
|
self.value = new
|
|
if self.on_change:
|
|
self.on_change(self)
|
|
return True
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"key": self.key,
|
|
"label": self.label,
|
|
"type": self.ptype.value,
|
|
"value": self.value,
|
|
"options": self.options,
|
|
"catalogue": self.catalogue,
|
|
"unit": self.unit,
|
|
"editable": self.editable,
|
|
"minimum": self.minimum,
|
|
"maximum": self.maximum,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "Property":
|
|
return cls(
|
|
data["key"],
|
|
data.get("label", data["key"]),
|
|
PropertyType(data["type"]),
|
|
data.get("value"),
|
|
options=data.get("options"),
|
|
catalogue=data.get("catalogue"),
|
|
unit=data.get("unit", ""),
|
|
editable=data.get("editable", True),
|
|
minimum=data.get("minimum"),
|
|
maximum=data.get("maximum"),
|
|
)
|
|
|
|
def clone(self) -> "Property":
|
|
return Property.from_dict(self.to_dict())
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return f"Property({self.key!r}={self.value!r}:{self.ptype.value})"
|
|
|
|
|
|
class PropertyGroup:
|
|
"""An ordered, named collection of properties (e.g. 'Geometry', 'Physics')."""
|
|
|
|
def __init__(self, name: str, properties: Optional[Iterable[Property]] = None) -> None:
|
|
self.name = name
|
|
self._props: list[Property] = list(properties or [])
|
|
|
|
def add(self, prop: Property) -> Property:
|
|
self._props.append(prop)
|
|
return prop
|
|
|
|
def get(self, key: str) -> Optional[Property]:
|
|
for p in self._props:
|
|
if p.key == key:
|
|
return p
|
|
return None
|
|
|
|
def value(self, key: str, default: Any = None) -> Any:
|
|
p = self.get(key)
|
|
return p.value if p is not None else default
|
|
|
|
def __iter__(self) -> Iterator[Property]:
|
|
return iter(self._props)
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._props)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {"name": self.name, "properties": [p.to_dict() for p in self._props]}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "PropertyGroup":
|
|
return cls(data["name"], [Property.from_dict(p) for p in data.get("properties", [])])
|
|
|
|
def clone(self) -> "PropertyGroup":
|
|
return PropertyGroup(self.name, [p.clone() for p in self._props])
|
|
|
|
|
|
class PropertyBag:
|
|
"""An ordered set of property groups with convenient key lookup.
|
|
|
|
Keys are unique across the whole bag, so ``bag.value("title")`` works
|
|
regardless of which group holds it.
|
|
"""
|
|
|
|
def __init__(self, groups: Optional[Iterable[PropertyGroup]] = None) -> None:
|
|
self._groups: list[PropertyGroup] = list(groups or [])
|
|
|
|
def add_group(self, group: PropertyGroup) -> PropertyGroup:
|
|
self._groups.append(group)
|
|
return group
|
|
|
|
def group(self, name: str) -> Optional[PropertyGroup]:
|
|
for g in self._groups:
|
|
if g.name == name:
|
|
return g
|
|
return None
|
|
|
|
def find(self, key: str) -> Optional[Property]:
|
|
for g in self._groups:
|
|
p = g.get(key)
|
|
if p is not None:
|
|
return p
|
|
return None
|
|
|
|
def value(self, key: str, default: Any = None) -> Any:
|
|
p = self.find(key)
|
|
return p.value if p is not None else default
|
|
|
|
def set_value(self, key: str, value: Any) -> bool:
|
|
p = self.find(key)
|
|
return p.set_value(value) if p is not None else False
|
|
|
|
def __iter__(self) -> Iterator[PropertyGroup]:
|
|
return iter(self._groups)
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._groups)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {"groups": [g.to_dict() for g in self._groups]}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "PropertyBag":
|
|
return cls([PropertyGroup.from_dict(g) for g in data.get("groups", [])])
|
|
|
|
def clone(self) -> "PropertyBag":
|
|
return PropertyBag([g.clone() for g in self._groups])
|