diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index e15081d..c5fa031 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -65,6 +65,7 @@ def init() -> None: """Initialize the extension.""" register_classes() register_interface_context() + register_physical_port_context() register_tree_view() register_realization_view() register_data_flow_view() @@ -248,6 +249,18 @@ def register_functional_context() -> None: ) +def register_physical_port_context() -> None: + """Add the `context_diagram` attribute to `PhysicalPort`s.""" + m.set_accessor( + cs.PhysicalPort, + ATTR_NAME, + context.PhysicalPortContextAccessor( + DiagramType.PAB.value, + {}, + ), + ) + + def register_tree_view() -> None: """Add the ``tree_view`` attribute to ``Class``es.""" m.set_accessor( @@ -324,6 +337,7 @@ def register_custom_diagram() -> None: (la.LogicalFunction, DiagramType.LAB), (pa.PhysicalFunction, DiagramType.PAB), (fa.ComponentExchange, DiagramType.SAB), + (cs.PhysicalPort, DiagramType.PAB), ] for class_, dgcls in supported_classes: m.set_accessor( diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 1c97519..8bab521 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -20,6 +20,12 @@ def _is_edge(obj: m.ModelElement) -> bool: return False +def _is_port(obj: m.ModelElement) -> bool: + if obj.xtype.endswith("Port"): + return True + return False + + class CustomCollector: """Collect the context for a custom diagram.""" @@ -30,9 +36,12 @@ def __init__( ) -> None: self.diagram = diagram self.target: m.ModelElement = self.diagram.target - self.boxable_target = ( - self.target.source.owner if _is_edge(self.target) else self.target - ) + if _is_port(self.target): + self.boxable_target = self.target.owner + elif _is_edge(self.target): + self.boxable_target = self.target.source.owner + else: + self.boxable_target = self.target self.data = makers.make_diagram(diagram) self.params = params self.instructions = self.diagram._collect @@ -54,7 +63,10 @@ def __init__( self.min_heights: dict[str, dict[str, float]] = {} def __call__(self) -> _elkjs.ELKInputData: - self._make_target(self.target) + if _is_port(self.target): + self._make_port_and_owner(self.target) + else: + self._make_target(self.target) if target_edge := self.edges.get(self.target.uuid): target_edge.layoutOptions = copy.deepcopy( _elkjs.EDGE_STRAIGHTENING_LAYOUT_OPTIONS @@ -116,7 +128,7 @@ def _perform_instructions( self.repeat_depth = max_depth if get_targets := instructions.get("get"): self._perform_get_or_include(obj, get_targets, False) - elif include_targets := instructions.get("include"): + if include_targets := instructions.get("include"): self._perform_get_or_include(obj, include_targets, True) if not get_targets and not include_targets: if self.repeat_depth != 0: @@ -310,6 +322,17 @@ def _need_switch( self.directions[tgt_uuid] = not src_dir if self.directions[src_uuid]: return True + elif self.diagram._unify_edge_direction == "TREE": + src_dir = self.directions.get(src_uuid) + tgt_dir = self.directions.get(tgt_uuid) + if (src_dir is None) and (tgt_dir is None): + self.directions[src_uuid] = True + self.directions[tgt_uuid] = True + elif src_dir is None: + self.directions[src_uuid] = True + return True + elif tgt_dir is None: + self.directions[tgt_uuid] = True return False def _make_port_and_owner( diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 60ab1d5..697d32e 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -123,6 +123,20 @@ def __get__( # type: ignore return self._get(obj, FunctionalContextDiagram) +class PhysicalPortContextAccessor(ContextAccessor): + def __get__( # type: ignore + self, + obj: m.T | None, + objtype: type | None = None, + ) -> m.Accessor | ContextDiagram: + """Make a ContextDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, m.ModelElement) + return self._get(obj, PhysicalPortContextDiagram) + + class ClassTreeAccessor(ContextAccessor): """Provides access to the tree view diagrams.""" @@ -916,14 +930,40 @@ def __init__( ) self.collector = custom.collector - @property - def uuid(self) -> str: # type: ignore - """Returns the UUID of the diagram.""" - return f"{self.target.uuid}_custom_diagram" - @property - def name(self) -> str: # type: ignore - return f"Custom Diagram of {self.target.name}" +class PhysicalPortContextDiagram(ContextDiagram): + """An automatically generated Context Diagram exclusively for + PhysicalPorts. + """ + + def __init__( + self, + class_: str, + obj: m.ModelElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], + ) -> None: + default_render_parameters = { + "collect": { + "repeat": -1, + "include": { + "name": "links", + "get": [{"name": "source"}, {"name": "target"}], + }, + }, + "display_parent_relation": True, + "unify_edge_direction": "TREE", + "display_port_labels": True, + "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) + self.collector = custom.collector def try_to_layout(data: _elkjs.ELKInputData) -> _elkjs.ELKOutputData: diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index 6c3d0fa..a644c85 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -26,6 +26,7 @@ TEST_ENTITY_UUID = "e37510b9-3166-4f80-a919-dfaac9b696c7" TEST_SYS_FNC_UUID = "a5642060-c9cc-4d49-af09-defaa3024bae" TEST_DERIVATION_UUID = "4ec45aec-0d6a-411a-80ee-ebd3c1a53d2c" +TEST_PHYSICAL_PORT_UUID = "c403d4f4-9633-42a2-a5d6-9e1df2655146" @pytest.mark.parametrize( @@ -53,13 +54,17 @@ "c78b5d7c-be0c-4ed4-9d12-d447cb39304e", id="PhysicalBehaviorComponent", ), + pytest.param( + TEST_PHYSICAL_PORT_UUID, + id="PhysicalPort", + ), ], ) def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: obj = model.by_uuid(uuid) diag = obj.context_diagram - diag.render(None, display_parent_relation=True) + diag.render("svgdiagram", display_parent_relation=True).save(pretty=True) diag.render(None, display_parent_relation=False) assert diag.nodes