-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.py
More file actions
2922 lines (2458 loc) · 126 KB
/
engine.py
File metadata and controls
2922 lines (2458 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
A Python module for creating a Qt5 canvas to design block diagrams with
connectable input and output pins.
This module provides the following classes:
- `Pin`: A namespace for pin type constants.
- `BlockPin`: Represents an input or output pin on a block.
- `DiagramPin`: A base class for standalone diagram input and output pins.
- `DiagramInputPin`: Represents a diagram's overall input.
- `DiagramOutputPin`: Represents a diagram's overall output.
- `Wire`: Represents a connection between two pins.
- `Block`: Represents a draggable and resizable block with pins.
- `RoutingManager`: Calculates smooth paths for wires.
- `BlockDiagramScene`: A QGraphicsScene for managing blocks and wires.
- `BlockDiagramView`: A QGraphicsView for displaying the scene, enabling panning and zooming.
- `MainWindow`: A QMainWindow to host the block diagram editor.
"""
from PyQt5.QtWidgets import (
QApplication, QGraphicsItem, QGraphicsScene, QGraphicsView, QMainWindow,
QGraphicsEllipseItem, QGraphicsPathItem, QMenu, QStyle, QStatusBar, QProgressBar,
QGraphicsRectItem, QGraphicsTextItem, QInputDialog, QGraphicsSceneContextMenuEvent, QGraphicsPolygonItem, QMessageBox, QGraphicsSceneMouseEvent, QGraphicsSceneHoverEvent,
QStyleOptionGraphicsItem, QWidget, QFileDialog
)
from PyQt5.QtCore import ( # Added QObject to imports
Qt, QPointF, QRectF, QLineF, QPoint,
pyqtSignal)
from PyQt5.QtGui import (
QPainter, QPen, QBrush, QFont, QPainterPath, QPolygonF, QPainterPathStroker, QColor, QKeyEvent, QWheelEvent, QMouseEvent, QCloseEvent, QTransform
)
from PyQt5.QtSvg import (
QSvgGenerator
)
import diagrams.conf as conf
from enum import Enum
import functools
from typing import Optional, Callable, Dict, Tuple, Union, List, Any
import math # For math.ceil
import itertools
import traceback
class PinType(Enum):
"""Defines the type of a pin, either as an input or an output."""
INPUT = 0
OUTPUT = 1
class MoveType(Enum):
"""Defines the type of optimization move."""
MOVE_BLOCK = 0
REORDER_BLOCK_PINS = 1
REORDER_DIAGRAM_PINS = 2
class OptimizationError(Exception):
"""Custom exception for errors during the optimization process."""
pass
class PinMixin:
"""
A mixin for Pin classes (BlockPin, DiagramPin) to share common logic.
This mixin provides attributes and methods related to connections (wires),
locking, and basic properties like name and type.
It assumes the class using it is a QGraphicsItem and that `init_pin` is called.
"""
def init_pin(self, parent_block: Optional['Block'], pin_type: PinType, name: str):
"""Initializes the common attributes for a pin."""
self.parent_block = parent_block
self.pin_type = pin_type
self._name = name
self.wires: List['Wire'] = []
def scenePos(self) -> QPointF:
"""
Returns the absolute scene position of the pin's center.
Returns:
QPointF: The center position of the pin in scene coordinates.
"""
return self.mapToScene(0, 0)
@property
def is_locked(self) -> bool:
"""A pin is considered locked if any of its connected wires are locked."""
return any(wire.is_locked for wire in self.wires)
def update_lock_state(self) -> None:
"""Updates the pin's appearance and movability based on its lock state."""
is_locked = self.is_locked # Check the dynamic property
self.setFlag(QGraphicsItem.ItemIsMovable, not is_locked)
if is_locked:
self.setBrush(QBrush(self.locked_color))
else:
# Revert to normal color. HoverMixin will handle hover highlight.
self.setBrush(QBrush(self.color))
self.update()
def update_connected_wires(self) -> None:
"""Updates the geometry of all wires connected to this pin."""
for wire in self.wires:
wire.update_geometry()
def single_selection_only(func: Callable) -> Callable:
"""
Decorator for contextMenuEvent methods to ensure they only run
when a single item is selected.
"""
@functools.wraps(func)
def wrapper(self: QGraphicsItem, event: QGraphicsSceneContextMenuEvent) -> None:
if self.scene() and len(self.scene().selectedItems()) > 1:
# Let the base class handle it, which might be nothing.
# This prevents showing an item-specific menu for a multi-selection.
super(self.__class__, self).contextMenuEvent(event)
return
return func(self, event)
return wrapper
def _is_rect_overlapping(scene: QGraphicsScene, rect: QRectF, item_to_ignore: QGraphicsItem) -> bool:
"""
Checks if a rectangle overlaps with any Block or DiagramPin in the scene.
Args:
scene (QGraphicsScene): The scene to check within.
rect (QRectF): The rectangle to check for overlaps.
item_to_ignore (QGraphicsItem): The item instance being placed, to
exclude it from collision checks.
Returns:
bool: True if the rectangle overlaps with an existing item, False otherwise.
"""
for item in scene.items():
if item == item_to_ignore:
continue
# We only care about collisions with other Blocks and DiagramPins
if isinstance(item, (Block, DiagramPin)):
if rect.intersects(item.sceneBoundingRect()):
return True
return False
def find_safe_placement(scene: QGraphicsScene,
item_width: float,
item_height: float,
item_to_ignore: QGraphicsItem,
search_center_hint: Optional[QPointF] = None,
is_centered: bool = False
) -> QPointF:
"""
Finds a non-overlapping position for an item in the scene.
Performs a deterministic spiral search outwards from the `search_center_hint`
to find the nearest available spot.
Args:
scene (QGraphicsScene): The scene to search within.
item_width (float): The width of the item to place.
item_height (float): The height of the item to place.
item_to_ignore (QGraphicsItem): The item instance being placed, to
exclude it from collision checks.
search_center_hint (QPointF, optional): The center point for the
search. Defaults to the scene origin.
is_centered (bool, optional): If True, the returned position is the
item's center. If False, it's the top-left corner. Defaults to False.
Returns:
QPointF: A safe position for the item.
"""
if search_center_hint is None:
search_center_hint = QPointF(0, 0)
# Start at the hint position, snapped to the grid
if is_centered:
start_pos = QPointF(round(search_center_hint.x() / conf.GRID_SIZE) * conf.GRID_SIZE, round(search_center_hint.y() / conf.GRID_SIZE) * conf.GRID_SIZE)
else:
start_pos = QPointF(round((search_center_hint.x() - item_width / 2) / conf.GRID_SIZE) * conf.GRID_SIZE, round((search_center_hint.y() - item_height / 2) / conf.GRID_SIZE) * conf.GRID_SIZE)
# Check the initial position first before starting the spiral.
initial_top_left = QPointF(start_pos.x() - item_width / 2, start_pos.y() - item_height / 2) if is_centered else start_pos
initial_rect = QRectF(initial_top_left.x(), initial_top_left.y(), item_width, item_height)
if not _is_rect_overlapping(scene, initial_rect, item_to_ignore):
return start_pos
x, y = 0, 0
dx, dy = 0, -conf.GRID_SIZE
max_radius_sq = conf.BLOCK_PLACEMENT_SEARCH_MAX_RADIUS ** 2
while x*x + y*y < max_radius_sq:
# This condition checks if we are at a "corner" of the spiral,
# which is where we need to turn. It generates the sequence:
# (right, down, left, left, up, up, right, right, right...)
if x == y or (x < 0 and x == -y) or (x > 0 and x == 1 - y):
dx, dy = -dy, dx
x, y = x + dx, y + dy
current_pos = QPointF(start_pos.x() + x, start_pos.y() + y)
potential_top_left = QPointF(current_pos.x() - item_width / 2, current_pos.y() - item_height / 2) if is_centered else current_pos
potential_rect = QRectF(potential_top_left.x(), potential_top_left.y(), item_width, item_height)
if not _is_rect_overlapping(scene, potential_rect, item_to_ignore):
return current_pos # Success
# If even the spiral search fails (scene is extremely crowded), return the
# original hint position as a last resort.
return start_pos
class SelectableMovableItemMixin:
"""
A mixin class to provide common mouse press event handling for selectable
and movable QGraphicsItems.
This mixin handles:
- Multi-selection with the Shift key.
- Single selection on right-click to prepare for a context menu,
clearing other selections if necessary.
"""
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
"""
Handles mouse press events for selection.
Args:
event (QGraphicsSceneMouseEvent): The mouse press event.
"""
# Handle multi-selection with Shift key
if event.button() == Qt.LeftButton and event.modifiers() == Qt.ShiftModifier:
self.setSelected(not self.isSelected())
return # Event handled
# Handle right-click for context menu
if event.button() == Qt.RightButton:
if not self.isSelected():
if self.scene():
self.scene().clearSelection()
self.setSelected(True)
return
super().mousePressEvent(event)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
"""
Handles item changes for snapping, wire updates, and selection.
This method is called by Qt when the item's state changes. It handles:
- Snapping the item to the grid when moved (`ItemPositionChange`).
- Updating connected wires after a move (`ItemPositionHasChanged`).
- Highlighting the item when selected (`ItemSelectedChange`).
Args:
change (QGraphicsItem.GraphicsItemChange): The type of change.
value: The new value associated with the change.
Returns:
The result of the base class's itemChange method.
"""
if change == QGraphicsItem.ItemPositionChange and self.scene():
new_pos = value
snapped_x = round(new_pos.x() / conf.GRID_SIZE) * conf.GRID_SIZE
snapped_y = round(new_pos.y() / conf.GRID_SIZE) * conf.GRID_SIZE
return QPointF(snapped_x, snapped_y)
elif change == QGraphicsItem.ItemPositionHasChanged:
self.update_connected_wires()
elif change == QGraphicsItem.ItemSelectedChange:
# The class using this mixin is responsible for defining
# these pen attributes if it wants selection highlighting.
if value and hasattr(self, 'highlight_pen'):
self.setPen(self.highlight_pen)
elif not value and hasattr(self, 'normal_pen'):
self.setPen(self.normal_pen)
return super().itemChange(change, value)
class HoverHighlightMixin:
"""
A mixin class to provide hover highlighting functionality.
This mixin assumes the class has `color` and `highlight_color` attributes
and that hover events have been enabled on the item.
"""
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent) -> None:
"""
Highlights the item by changing its brush to the highlight color.
If the item has an 'is_locked' attribute and it is True, the
highlighting is skipped.
Args:
event (QGraphicsSceneHoverEvent): The hover event.
"""
if hasattr(self, 'is_locked') and self.is_locked:
super().hoverEnterEvent(event)
return # Do not highlight if locked
self.setBrush(QBrush(self.highlight_color))
super().hoverEnterEvent(event)
def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent) -> None:
"""
Resets the item's brush to its default color when the mouse leaves.
If the item is locked, the brush is set to its 'locked_color' instead.
"""
if hasattr(self, 'is_locked') and self.is_locked:
# If it's locked, ensure it has the locked brush.
if hasattr(self, 'locked_color'):
self.setBrush(QBrush(self.locked_color))
super().hoverLeaveEvent(event)
return
self.setBrush(QBrush(self.color))
super().hoverLeaveEvent(event)
class BlockPin(PinMixin, HoverHighlightMixin, QGraphicsEllipseItem):
"""
Represents an input or output pin on a block.
Pins are circular QGraphicsEllipseItem instances attached to a Block.
They handle their own positioning, display, and hover events.
Attributes:
parent_block (Block): The block this pin belongs to.
pin_type (PinType): The type of the pin (PinType.INPUT or PinType.OUTPUT).
name (str): The name of the pin, displayed next to it.
index (int): The vertical index of the pin on its side of the block.
wires (list): A list of Wire objects connected to this pin.
color (QColor): The default color of the pin.
highlight_color (QColor): The color of the pin on hover.
text_item (QGraphicsTextItem): The text label for the pin.
"""
def __init__(self, parent_block: 'Block', pin_type: PinType, name: str = "", index: int = 0) -> None:
"""
Initializes a BlockPin.
Args:
parent_block (Block): The parent Block item.
pin_type (PinType): The type of the pin (PinType.INPUT or PinType.OUTPUT).
name (str, optional): The name of the pin. Defaults to "".
index (int, optional): The index of the pin, used for vertical positioning. Defaults to 0.
"""
super().__init__(-conf.BLOCK_PIN_RADIUS, -conf.BLOCK_PIN_RADIUS, conf.BLOCK_PIN_DIAMETER_SCALE * conf.BLOCK_PIN_RADIUS, conf.BLOCK_PIN_DIAMETER_SCALE * conf.BLOCK_PIN_RADIUS, parent=parent_block)
self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) # Must be true for itemChange to be called
self.setAcceptHoverEvents(True)
self.init_pin(parent_block, pin_type, name)
self.index = index # Index among pins of the same type
self.color = conf.BLOCK_PIN_COLOR
self.highlight_color = conf.BLOCK_PIN_HIGHLIGHT_COLOR
self.locked_color = conf.BLOCK_PIN_LOCKED_COLOR
self.setBrush(QBrush(self.color))
self.setPen(QPen(conf.PEN_STYLE_NO_PEN)) # Pins don't have a border by default
self.setZValue(conf.Z_VALUE_PIN) # Pins should be on top of the block
self.text_item = QGraphicsTextItem(self.name, self)
self.text_item.setDefaultTextColor(conf.BLOCK_TEXT_COLOR)
font = QFont()
font.setPointSize(conf.FONT_SIZE_BLOCK_PIN)
self.text_item.setFont(font)
self.text_item.setZValue(conf.Z_VALUE_TEXT) # Text on top of pin
self.update_lock_state()
self.update_position()
@property
def name(self) -> str:
"""Returns the name of the pin."""
return self._name
def update_position(self) -> None:
"""
Recalculates and sets the position of the pin and its text.
The position is based on a fixed vertical spacing defined in conf.py,
ensuring pins are always on the grid.
"""
block_width = self.parent_block.rect().width()
# Calculate Y position based on fixed, grid-aligned spacing.
# This is independent of the block's final height.
y = conf.BLOCK_PIN_TOP_PADDING + (self.index * conf.BLOCK_PIN_VERTICAL_SPACING)
if self.pin_type == PinType.INPUT:
x = 0
self.setPos(x, y)
self.text_item.setPos(conf.BLOCK_PIN_RADIUS + conf.BLOCK_PIN_TEXT_PADDING, -self.text_item.boundingRect().height() / 2)
else: # OUTPUT
x = block_width
self.setPos(x, y)
self.text_item.setPos(-conf.BLOCK_PIN_RADIUS - conf.BLOCK_PIN_TEXT_PADDING - self.text_item.boundingRect().width(), -self.text_item.boundingRect().height() / 2)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
"""
Handles item changes to constrain movement and trigger realignment.
Args:
change (QGraphicsItem.GraphicsItemChange): The type of change.
value (Any): The new value associated with the change.
Returns:
Any: The result of the base class's itemChange method.
"""
if change == QGraphicsItem.ItemPositionChange:
new_pos = value
# Determine the correct, fixed x-position based on pin type.
# The pin's position is relative to the parent block.
if self.pin_type == PinType.INPUT:
fixed_x = 0
else: # OUTPUT
fixed_x = self.parent_block.rect().width()
# Clamp y within the block's vertical bounds
block_height = self.parent_block.rect().height()
clamped_y = max(0, min(new_pos.y(), block_height))
# Return the constrained position.
return QPointF(fixed_x, clamped_y)
return super().itemChange(change, value)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
"""
Starts a wire drag on Ctrl+Click, otherwise prepares for movement
by disabling parent block's pin realignment.
Args:
event (QGraphicsSceneMouseEvent): The mouse press event.
"""
if event.button() == Qt.LeftButton and event.modifiers() == Qt.ControlModifier:
if self.scene():
# The scene is responsible for managing wire creation
self.scene()._start_wire_drag(self)
event.accept()
return # Consume event
# For regular clicks that will initiate a move, disable realignment.
if self.parent_block and hasattr(self.parent_block, 'set_pin_realign_enabled'):
self.parent_block.set_pin_realign_enabled(False)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
"""
Re-enables and triggers pin realignment on the parent block after a drag.
Args:
event (QGraphicsSceneMouseEvent): The mouse release event.
"""
super().mouseReleaseEvent(event)
if self.parent_block and hasattr(self.parent_block, 'set_pin_realign_enabled'):
self.parent_block.set_pin_realign_enabled(True)
self.parent_block.realign_pins()
class DiagramPin(PinMixin, HoverHighlightMixin, SelectableMovableItemMixin, QGraphicsPolygonItem):
"""
Base class for standalone diagram input and output pins.
These pins are not attached to a block and represent the overall inputs
and outputs of the diagram. They are movable and can be renamed.
This class handles common functionality like movement, selection,
hover effects, and context menus.
Attributes:
name (str): The name of the pin, displayed next to it.
wires (list): A list of Wire objects connected to this pin.
parent_block (None): Diagram pins are standalone.
log_func (callable): A function for logging messages.
color (QColor): The default color of the pin.
highlight_color (QColor): The color of the pin on hover.
pin_type (PinType): The type of the pin (PinType.INPUT or PinType.OUTPUT).
text_item (QGraphicsTextItem): The text label for the pin.
"""
# Class-level constant for the diamond shape to avoid recreating it on every instantiation.
DIAMOND_POLYGON = QPolygonF([
QPointF(0, -conf.DIAGRAM_PIN_RADIUS * conf.DIAGRAM_PIN_DIAMOND_SCALE),
QPointF(conf.DIAGRAM_PIN_RADIUS * conf.DIAGRAM_PIN_DIAMOND_SCALE, 0),
QPointF(0, conf.DIAGRAM_PIN_RADIUS * conf.DIAGRAM_PIN_DIAMOND_SCALE),
QPointF(-conf.DIAGRAM_PIN_RADIUS * conf.DIAGRAM_PIN_DIAMOND_SCALE, 0)
])
def __init__(self,
name: str,
pin_type: PinType,
x: Optional[float],
y: Optional[float],
scene_for_auto_placement: Optional[QGraphicsScene] = None,
placement_hint: Optional[QPointF] = None,
log_func: Optional[Callable[[str], None]] = None
) -> None:
"""
Initializes a DiagramPin.
Args:
name (str): The name of the pin.
x (Optional[float]): The initial x-coordinate. If None, auto-placement is used.
y (Optional[float]): The initial y-coordinate. If None, auto-placement is used.
pin_type (PinType): The type of the pin (PinType.INPUT or PinType.OUTPUT).
scene_for_auto_placement (Optional[QGraphicsScene]): The scene for auto-placement.
placement_hint (QPointF, optional): A hint for where to place the pin
during auto-placement. Defaults to None.
log_func (Optional[Callable[[str], None]]): A function for logging messages.
"""
super().__init__(self.DIAMOND_POLYGON) # Call QGraphicsPolygonItem constructor
self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
self.setAcceptHoverEvents(True)
self.setZValue(conf.Z_VALUE_PIN)
self.normal_pen = QPen(conf.BLOCK_BORDER_COLOR, conf.PEN_WIDTH_NORMAL)
self.highlight_pen = QPen(conf.BLOCK_HIGHLIGHT_COLOR, conf.PEN_WIDTH_HIGHLIGHT)
self.setPen(self.normal_pen)
self.init_pin(parent_block=None, pin_type=pin_type, name=name)
self.log_func = log_func if log_func else print # log_func is specific to DiagramPin/Block
# Set colors based on pin type
if self.pin_type == PinType.OUTPUT: # DiagramInputPin
self.color = conf.DIAGRAM_PIN_COLOR
self.highlight_color = conf.DIAGRAM_PIN_HIGHLIGHT_COLOR
self.locked_color = conf.DIAGRAM_PIN_LOCKED_COLOR
else: # INPUT, for DiagramOutputPin
self.color = conf.DIAGRAM_OUTPUT_PIN_COLOR
self.highlight_color = conf.DIAGRAM_OUTPUT_PIN_HIGHLIGHT_COLOR
self.locked_color = conf.DIAGRAM_OUTPUT_PIN_LOCKED_COLOR
self.setBrush(QBrush(self.color))
self.text_item = QGraphicsTextItem(self._name, self)
self.text_item.setDefaultTextColor(conf.DIAGRAM_PIN_TEXT_COLOR)
font = QFont()
font.setPointSize(conf.FONT_SIZE_DIAGRAM_PIN)
self.text_item.setFont(font)
self.text_item.setZValue(conf.Z_VALUE_TEXT)
self.update_lock_state()
self._update_text_position()
if x is not None and y is not None:
# Snap to grid if coordinates are provided manually
snapped_x = round(x / conf.GRID_SIZE) * conf.GRID_SIZE
snapped_y = round(y / conf.GRID_SIZE) * conf.GRID_SIZE
self.setPos(snapped_x, snapped_y)
elif scene_for_auto_placement is not None:
pin_rect = self.boundingRect()
item_width = pin_rect.width()
item_height = pin_rect.height()
safe_pos = find_safe_placement(
scene_for_auto_placement,
item_width,
item_height,
item_to_ignore=self,
search_center_hint=placement_hint,
is_centered=True # DiagramPin position is its center
)
self.setPos(safe_pos)
def _update_text_position(self) -> None:
"""Positions the text label relative to the pin."""
text_rect = self.text_item.boundingRect()
text_y = -text_rect.height() / 2
# Common horizontal offset from the diamond's edge
horizontal_offset = conf.DIAGRAM_PIN_RADIUS * conf.DIAGRAM_PIN_DIAMOND_SCALE + conf.DIAGRAM_PIN_TEXT_PADDING
# DiagramInputPin acts as an OUTPUT, text on the left.
# DiagramOutputPin acts as an INPUT, text on the right.
if self.pin_type == PinType.OUTPUT:
# Position text to the left
text_x = -horizontal_offset - text_rect.width()
else: # INPUT
# Position text to the right
text_x = horizontal_offset
self.text_item.setPos(text_x, text_y)
@property
def name(self) -> str:
"""Returns the name of the diagram pin."""
return self._name
@name.setter
def name(self, new_name: str) -> None:
"""
Sets a new name for the pin and updates its visual representation.
Args:
new_name (str): The new name for the pin.
"""
self._name = new_name
self.text_item.setPlainText(self._name)
self._update_text_position()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
"""
Disables scene-wide pin realignment during a drag operation.
Args:
event (QGraphicsSceneMouseEvent): The mouse press event.
"""
if self.scene() and hasattr(self.scene(), 'set_realign_enabled'):
self.scene().set_realign_enabled(False)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
"""
Re-enables and triggers scene-wide pin realignment after a drag.
Args:
event (QGraphicsSceneMouseEvent): The mouse release event.
"""
super().mouseReleaseEvent(event)
if self.scene() and hasattr(self.scene(), 'set_realign_enabled'):
self.scene().set_realign_enabled(True)
# Trigger a final realignment now that the drag is complete.
self.scene().realign_diagram_pins()
def request_rename(self) -> None:
"""
Handles the logic for when a rename is requested from the context menu.
Emits the `renameDiagramPinRequested` signal on the scene.
"""
if self.scene():
if hasattr(self.scene(), 'renameDiagramPinRequested'):
self.scene().renameDiagramPinRequested.emit(self)
def _get_context_menu_texts(self) -> Tuple[str, str]:
"""Abstract method to be implemented by subclasses for context menu text."""
raise NotImplementedError(conf.UI.Log.NOT_IMPLEMENTED_ERROR_SUBCLASS)
def _base_context_menu(self, event: QGraphicsSceneContextMenuEvent, rename_text: str, delete_text: str) -> None:
"""
Helper for creating the context menu.
Args:
event (QGraphicsSceneContextMenuEvent): The context menu event.
rename_text (str): The text for the 'Rename' action.
delete_text (str): The text for the 'Delete' action.
"""
menu = QMenu()
rename_action = menu.addAction(rename_text)
delete_action = menu.addAction(delete_text)
action = menu.exec_(event.screenPos())
if action == delete_action:
if self.scene():
self.setSelected(True)
self.scene().delete_selected_items()
elif action == rename_action:
self.request_rename()
@single_selection_only
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
"""
Shows a context menu for the diagram pin.
Args:
event (QGraphicsSceneContextMenuEvent): The context menu event.
"""
rename_text, delete_text = self._get_context_menu_texts()
self._base_context_menu(event, rename_text, delete_text)
class DiagramInputPin(DiagramPin):
"""
Represents a standalone input pin for the entire diagram.
It acts as an output pin within the diagram's logic, providing a signal source.
"""
def __init__(self,
name: str,
x: Optional[float] = None,
y: Optional[float] = None,
scene_for_auto_placement: Optional[QGraphicsScene] = None,
placement_hint: Optional[QPointF] = None,
log_func: Optional[Callable[[str], None]] = None
) -> None:
"""
Initializes a DiagramInputPin.
Args:
name (str): The name of the pin.
x (Optional[float]): The initial x-coordinate. Defaults to None.
y (Optional[float]): The initial y-coordinate. Defaults to None.
scene_for_auto_placement (Optional[QGraphicsScene]): The scene for auto-placement.
placement_hint (QPointF, optional): A hint for auto-placement.
log_func (Optional[Callable[[str], None]]): A function for logging.
"""
super().__init__(name=name,
pin_type=PinType.OUTPUT,
x=x, y=y,
scene_for_auto_placement=scene_for_auto_placement,
placement_hint=placement_hint,
log_func=log_func)
def _get_context_menu_texts(self) -> Tuple[str, str]:
"""Provides the specific context menu texts for a DiagramInputPin."""
return (conf.UI.Menu.RENAME_DIAGRAM_INPUT, conf.UI.Menu.DELETE_DIAGRAM_INPUT)
class DiagramOutputPin(DiagramPin):
"""
Represents a standalone output pin for the entire diagram.
It acts as an input pin within the diagram's logic, providing a signal sink.
"""
def __init__(self,
name: str,
x: Optional[float] = None,
y: Optional[float] = None,
scene_for_auto_placement: Optional[QGraphicsScene] = None,
placement_hint: Optional[QPointF] = None,
log_func: Optional[Callable[[str], None]] = None
) -> None:
"""
Initializes a DiagramOutputPin.
Args:
name (str): The name of the pin.
x (Optional[float]): The initial x-coordinate. Defaults to None.
y (Optional[float]): The initial y-coordinate. Defaults to None.
scene_for_auto_placement (Optional[QGraphicsScene]): The scene for auto-placement.
placement_hint (QPointF, optional): A hint for auto-placement.
log_func (Optional[Callable[[str], None]]): A function for logging.
"""
super().__init__(name=name,
pin_type=PinType.INPUT,
x=x, y=y,
scene_for_auto_placement=scene_for_auto_placement,
placement_hint=placement_hint,
log_func=log_func)
def _get_context_menu_texts(self) -> Tuple[str, str]:
"""Provides the specific context menu texts for a DiagramOutputPin."""
return (conf.UI.Menu.RENAME_DIAGRAM_OUTPUT, conf.UI.Menu.DELETE_DIAGRAM_OUTPUT)
Pin = Union[BlockPin, DiagramPin]
class Wire(SelectableMovableItemMixin, QGraphicsPathItem):
"""
Represents a visual connection (wire) between two pins.
The wire is drawn as a cubic Bezier curve. It handles its own
geometry updates when connected pins move. It also manages its
selection state and provides a context menu for deletion.
Attributes:
start_pin (Pin): The pin where the wire originates (source).
end_pin (Pin): The pin where the wire terminates (destination).
routing_manager (RoutingManager): The object responsible for
calculating the wire's path.
_temp_end_pos (QPointF): A temporary position for the end of the
wire, used when the user is dragging a new connection.
"""
def __init__(self,
start_pin: Pin,
end_pin: Optional[Pin] = None,
routing_manager: Optional['RoutingManager'] = None
) -> None:
"""
Initializes a Wire.
Args:
start_pin (Pin): The pin where the wire starts.
end_pin (Optional[Pin]): The pin where the wire ends. Can be None for
a temporary wire during creation. Defaults to None.
routing_manager (Optional['RoutingManager']): The manager for calculating
the wire's path. Defaults to None.
"""
super().__init__()
self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.normal_pen = QPen(conf.WIRE_COLOR, conf.PEN_WIDTH_NORMAL)
self.highlight_pen = QPen(conf.WIRE_HIGHLIGHT_COLOR, conf.PEN_WIDTH_HIGHLIGHT)
self.locked_pen = QPen(conf.WIRE_LOCKED_COLOR, conf.PEN_WIDTH_NORMAL)
self.setPen(self.normal_pen)
self.setZValue(conf.Z_VALUE_WIRE) # Wires should be below blocks (blocks are Z=2)
self.is_locked = False
self.start_pin = start_pin
self.end_pin = end_pin
self._temp_end_pos = None # For drawing wire during creation drag
# Use duck-typing to ensure the routing manager has the required method.
if not routing_manager or not hasattr(routing_manager, 'calculate_path') or not callable(getattr(routing_manager, 'calculate_path')):
raise ValueError(conf.UI.Log.ROUTING_MANAGER_INVALID)
self.routing_manager = routing_manager
if self.start_pin: # start_pin could be None if wire is created improperly (defensive)
self.start_pin.wires.append(self)
if self.end_pin:
self.end_pin.wires.append(self)
self.update_geometry() # Initial draw
def shape(self) -> QPainterPath:
"""
Returns the shape of this item as a QPainterPath for collision detection.
This implementation returns a wider path than the drawn one to make
it easier to click on the wire.
Returns:
QPainterPath: The shape of the wire for hit testing.
"""
stroker = QPainterPathStroker()
stroker.setWidth(conf.WIRE_CLICKABLE_WIDTH)
return stroker.createStroke(self.path())
def set_locked(self, locked: bool) -> None:
"""
Sets the locked state of the wire, preventing pin reordering and changing its appearance.
"""
self.is_locked = locked
if locked:
self.setPen(self.locked_pen)
else:
# Revert to normal or highlight pen based on selection state
if self.isSelected():
self.setPen(self.highlight_pen)
else:
self.setPen(self.normal_pen)
self.update()
# Notify connected pins that their lock state may have changed.
if self.start_pin and hasattr(self.start_pin, 'update_lock_state'):
self.start_pin.update_lock_state()
if self.end_pin and hasattr(self.end_pin, 'update_lock_state'):
self.end_pin.update_lock_state()
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
"""
Overrides the mixin's itemChange to handle selection changes for locked wires.
Args:
change (QGraphicsItem.GraphicsItemChange): The type of change.
value (Any): The new value associated with the change.
Returns:
Any: The result of the base class's itemChange method.
"""
if change == QGraphicsItem.ItemSelectedChange and self.is_locked:
# Don't change the pen for selection if locked.
return QGraphicsPathItem.itemChange(self, change, value)
return super().itemChange(change, value)
def set_end_pin(self, pin: Pin) -> None:
"""
Sets or updates the end pin of the wire and updates geometry.
Args:
pin (Pin): The new end pin for the wire.
"""
# Remove from old end_pin's wire list if it exists and is different
if self.end_pin and self.end_pin != pin and self in self.end_pin.wires:
self.end_pin.wires.remove(self)
self.end_pin = pin
self._temp_end_pos = None # No longer a temporary wire being dragged
if self.end_pin:
if self not in self.end_pin.wires:
self.end_pin.wires.append(self)
if hasattr(self.end_pin, 'update_lock_state'):
self.end_pin.update_lock_state()
self.update_geometry()
def update_temp_end_pos(self, scene_pos: QPointF) -> None:
"""
Updates the temporary end position when dragging a new wire.
Args:
scene_pos (QPointF): The current mouse position in scene coordinates.
"""
if self.end_pin is None: # Only if it's a temporary wire
self._temp_end_pos = scene_pos
self.update_geometry()
def update_geometry(self) -> None:
"""
Updates the wire's line based on the current positions of its
start and end pins, using the routing manager.
"""
if not self.start_pin or not self.routing_manager:
self.setPath(QPainterPath()) # Empty path
return
start_pos = self.start_pin.scenePos()
path = QPainterPath() # Default to empty path
if self.end_pin:
end_pos = self.end_pin.scenePos()
path = self.routing_manager.calculate_path(
start_pos,
end_pos,
self.start_pin.pin_type,
self.end_pin.pin_type,
is_temporary=False,
wire_being_routed=self
)
elif self._temp_end_pos:
path = self.routing_manager.calculate_path(
start_pos,
self._temp_end_pos,
self.start_pin.pin_type,
end_pin_type=None, # Not applicable for temp wire's moving end
is_temporary=True,
wire_being_routed=self
)
# path will be empty if no end_pin or temp_end_pos, or if routing_manager returns empty.
self.setPath(path) # Set the calculated path
@single_selection_only
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
"""
Shows a context menu for the wire.
Args:
event (QGraphicsSceneContextMenuEvent): The context menu event.
"""
menu = QMenu()
# Add Lock/Unlock action
lock_text = conf.UI.Menu.UNLOCK_WIRE if self.is_locked else conf.UI.Menu.LOCK_WIRE
lock_action = menu.addAction(lock_text)
menu.addSeparator()
delete_action = menu.addAction(conf.UI.Menu.DELETE_WIRE)
# The scene() method gives us access to the BlockDiagramScene instance
# which has the logic to properly remove wires.
action = menu.exec_(event.screenPos())
if action == lock_action:
self.set_locked(not self.is_locked)
elif action == delete_action:
if self.scene():
self.scene().remove_wire(self)
def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None:
"""
Custom paint method to prevent drawing the default selection rectangle,
as selection is indicated by changing the wire's color and width.
Args:
painter (QPainter): The painter to use.
option (QStyleOptionGraphicsItem): The style options.
widget (QWidget, optional): The widget being painted on. Defaults to None.
"""
# We need to save and restore the state of the option, as it's
# passed by reference and modifying it can have side effects.
original_state = option.state
# If the item is selected, we remove the 'Selected' state flag
# before calling the parent's paint method.
if option.state & QStyle.State_Selected:
option.state &= ~QStyle.State_Selected
super().paint(painter, option, widget)
# Restore the original state
option.state = original_state
class Block(SelectableMovableItemMixin, QGraphicsRectItem):
"""
Represents a draggable block with a title and connectable pins.
The block can be moved and selected. It automatically adjusts its size
based on its title and the number of input/output pins. It provides
a context menu for actions like renaming and adding pins.
Attributes:
name (str): The name of the block, displayed as its title.
input_pins (dict): A dictionary of input pins, keyed by pin name.
output_pins (dict): A dictionary of output pins, keyed by pin name.
log_func (callable): A function for logging messages.
title_item (QGraphicsTextItem): The text item for the block's title.
"""
def __init__(self,
name: str = 'Block',
x: Optional[float] = None,
y: Optional[float] = None,
scene_for_auto_placement: Optional[QGraphicsScene] = None,
placement_hint: Optional[QPointF] = None,
log_func: Optional[Callable[[str], None]] = None
) -> None:
"""
Initializes a Block.
Args:
name (str, optional): The name of the block. Defaults to 'Block'.
x (float, optional): The initial x-coordinate. Defaults to None for auto-placement.
y (float, optional): The initial y-coordinate. Defaults to None for auto-placement.
scene_for_auto_placement (QGraphicsScene, optional): The scene for auto-placement.
placement_hint (QPointF, optional): A hint for auto-placement.
log_func (Callable[[str], None], optional): A function for logging.
"""
super().__init__(conf.INITIAL_ITEM_X, conf.INITIAL_ITEM_Y, conf.MIN_ITEM_DIMENSION, conf.MIN_ITEM_DIMENSION) # Initialize with min rect, set_size will fix it
self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
self.setAcceptHoverEvents(True)
self.setZValue(conf.Z_VALUE_BLOCK) # Blocks should be above wires
self.is_locked = False
self.log_func = log_func if log_func else print
self._pin_realign_enabled = True