"""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])