Skip to content

Commit 50abf57

Browse files
committed
fixing self-loops transitions in editor
1 parent 6def4af commit 50abf57

File tree

2 files changed

+102
-10
lines changed

2 files changed

+102
-10
lines changed

yasmin_editor/yasmin_editor/editor_gui/connection_line.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@ def update_position(self) -> None:
178178
179179
Recalculates the cubic Bezier curve path between nodes, including
180180
offset for multiple connections, arrow head position, and label placement.
181+
For self-loops, draws a loop arc above the node.
181182
"""
183+
# Check if this is a self-loop
184+
if self.from_node == self.to_node:
185+
self._update_self_loop_position()
186+
return
187+
182188
from_center: QPointF = self.from_node.get_connection_point()
183189
to_center: QPointF = self.to_node.get_connection_point()
184190
from_pos: QPointF = self.from_node.get_edge_point(to_center)
@@ -238,3 +244,93 @@ def update_position(self) -> None:
238244
mid_point.x() - label_rect.width() / 2,
239245
mid_point.y() - label_rect.height() / 2,
240246
)
247+
248+
def _update_self_loop_position(self) -> None:
249+
"""Update position for a self-loop (transition from a state to itself).
250+
251+
Draws a loop arc above the node with an arrow pointing back into the node.
252+
Multiple self-loops on the same node are offset to avoid overlap.
253+
"""
254+
node = self.from_node
255+
center: QPointF = node.get_connection_point()
256+
257+
# Calculate offset for multiple self-loops on the same node
258+
self_loops = [
259+
conn
260+
for conn in node.connections
261+
if conn.from_node == conn.to_node and conn.from_node == node
262+
]
263+
self_loops.sort(key=lambda c: c.outcome)
264+
265+
try:
266+
loop_index: int = self_loops.index(self)
267+
except ValueError:
268+
loop_index = 0
269+
270+
num_loops: int = len(self_loops)
271+
272+
# Base loop parameters
273+
loop_height: float = 80 # How far the loop extends above the node
274+
loop_width: float = 60 # Width of the loop
275+
276+
# Offset multiple loops horizontally
277+
horizontal_spacing: float = 50
278+
if num_loops > 1:
279+
center_offset = (num_loops - 1) / 2
280+
horizontal_offset = (loop_index - center_offset) * horizontal_spacing
281+
else:
282+
horizontal_offset = 0
283+
284+
# Calculate start and end points on the top edge of the node
285+
# Start point is slightly to the left, end point slightly to the right
286+
start_x = center.x() + horizontal_offset - 20
287+
end_x = center.x() + horizontal_offset + 20
288+
289+
# Get the top of the node (approximate)
290+
node_top = center.y() - 40 # Approximate top of node
291+
292+
start_pos = QPointF(start_x, node_top)
293+
end_pos = QPointF(end_x, node_top)
294+
295+
# Control points for the loop curve (above the node)
296+
ctrl1 = QPointF(start_x - loop_width / 2, node_top - loop_height)
297+
ctrl2 = QPointF(end_x + loop_width / 2, node_top - loop_height)
298+
299+
# Create the path
300+
path: QPainterPath = QPainterPath()
301+
path.moveTo(start_pos)
302+
path.cubicTo(ctrl1, ctrl2, end_pos)
303+
self.setPath(path)
304+
305+
# Arrow head pointing down into the node
306+
arrow_size: float = 12
307+
# Arrow points downward
308+
angle = math.pi / 2 # 90 degrees (pointing down)
309+
310+
arrow_p1: QPointF = end_pos - QPointF(
311+
math.cos(angle - math.pi / 6) * arrow_size,
312+
math.sin(angle - math.pi / 6) * arrow_size,
313+
)
314+
arrow_p2: QPointF = end_pos - QPointF(
315+
math.cos(angle + math.pi / 6) * arrow_size,
316+
math.sin(angle + math.pi / 6) * arrow_size,
317+
)
318+
319+
arrow_polygon: QPolygonF = QPolygonF([end_pos, arrow_p1, arrow_p2])
320+
self.arrow_head.setPolygon(arrow_polygon)
321+
322+
# Position label at the top of the loop
323+
mid_point = QPointF(center.x() + horizontal_offset, node_top - loop_height + 10)
324+
label_rect = self.label.boundingRect()
325+
padding: float = 4
326+
327+
self.label_bg.setRect(
328+
mid_point.x() - label_rect.width() / 2 - padding,
329+
mid_point.y() - label_rect.height() / 2 - padding,
330+
label_rect.width() + padding * 2,
331+
label_rect.height() + padding * 2,
332+
)
333+
self.label.setPos(
334+
mid_point.x() - label_rect.width() / 2,
335+
mid_point.y() - label_rect.height() / 2,
336+
)

yasmin_editor/yasmin_editor/editor_gui/state_machine_canvas.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ def is_valid_connection(
5757
) -> bool:
5858
"""Validate if a connection between two nodes is allowed."""
5959
if source_node == target_node:
60-
if isinstance(source_node, StateNode):
61-
source_container = getattr(source_node, "parent_container", None)
62-
if source_container and isinstance(source_container, ContainerStateNode):
63-
if source_container.is_concurrence:
64-
return True
60+
# Allow self-loops for regular StateNodes (not containers or final outcomes)
61+
if isinstance(source_node, StateNode) and not isinstance(
62+
source_node, ContainerStateNode
63+
):
64+
return True
6565
return False
6666

6767
source_container: Optional["ContainerStateNode"] = getattr(
@@ -154,11 +154,7 @@ def mouseMoveEvent(self, event: QEvent) -> None:
154154
if isinstance(parent, (StateNode, FinalOutcomeNode, ContainerStateNode)):
155155
target = parent
156156

157-
if (
158-
target
159-
and target != self.drag_start_node
160-
and self.is_valid_connection(self.drag_start_node, target)
161-
):
157+
if target and self.is_valid_connection(self.drag_start_node, target):
162158
target.setOpacity(0.6)
163159

164160
event.accept()

0 commit comments

Comments
 (0)