diff --git a/docs/Developer_Guide/test_coverage_groupes_visuels.md b/docs/Developer_Guide/test_coverage_groupes_visuels.md new file mode 100644 index 0000000..0fba703 --- /dev/null +++ b/docs/Developer_Guide/test_coverage_groupes_visuels.md @@ -0,0 +1,194 @@ +# Couverture des tests — groupes visuels + +Ce document recense les **77 tests** dédiés aux groupes visuels, répartis en **8 fichiers** sous `tests/gui/`. + +--- + +## Synthèse + +| Fichier | Tests | Domaine principal | +|---------|------:|-------------------| +| `test_visual_group_controller.py` | 8 | Logique contrôleur (CRUD groupe, membres, frontières) | +| `test_visual_group_view.py` | 14 | Vue, navigation, raccourcis, proxies, undo layout | +| `test_visual_group_persistence.py` | 4 | Sérialisation YAML (save/load, rétrocompatibilité) | +| `test_nested_visual_groups.py` | 8 | Hiérarchie imbriquée, visibilité, suppression | +| `test_group_orientation.py` | 3 | Ctrl+R sur groupe et proxy In/Out | +| `test_group_boundary_labels.py` | 4 | Libellés des ports de frontière | +| `test_diagram_clipboard.py` | 8 | Copier-coller blocs, groupes, proxies | +| `test_manual_boundary_wiring.py` | 28 | Câblage partiel, fils `---`, reconnexion (In **et** Out) | +| **Total** | **77** | | + +--- + +## `test_visual_group_controller.py` + +Logique `ProjectController` : création, suppression, appartenance, frontières auto. + +| Test | Cas couvert | +|------|-------------| +| `test_group_blocks_creates_visual_group_with_boundaries` | Groupage de 2 blocs avec connexion interne + sortie vers l'extérieur ; frontière auto de direction `output` | +| `test_ungroup_removes_visual_group` | Dé-groupement supprime l'entité ; `ungroup` sur uid absent retourne `False` | +| `test_remove_block_updates_group_membership` | `remove_block` retire le membre du groupe ; groupe supprimé quand vide | +| `test_add_block_to_group_moves_membership` | `add_block_to_group` ajoute un bloc existant au groupe (undoable) | +| `test_add_block_in_group_view_creates_member` | Drop palette en vue interne (`add_block_in_group_view`) crée et ajoute le bloc | +| `test_remove_block_from_group_keeps_block_visible_at_parent` | `remove_block_from_group` retire le membre sans supprimer le bloc ; réaffichage au parent | +| `test_cannot_add_block_already_owned_by_another_group` | Un bloc déjà membre d'un groupe ne peut pas être ajouté à un autre | +| `test_boundary_ports_recomputed_when_connections_change` | Ajout d'une connexion traversante → nouvelle frontière `input` ; suppression → frontière orpheline conservée (uid membre, pas de `linked_connection_uid`) | + +--- + +## `test_visual_group_view.py` + +Interaction `DiagramView` : visibilité, navigation, raccourcis, proxies, ports manuels. + +| Test | Cas couvert | +|------|-------------| +| `test_group_hides_members_and_shows_group_item` | Au niveau racine : membres masqués, `GroupItem` visible, blocs externes visibles | +| `test_group_captures_member_layouts_and_internal_view_applies_them` | Positions capturées au groupage ; restaurées en vue interne ; sauvegardées à la sortie | +| `test_double_click_enters_group_view` | Entrée dans le groupe : membres visibles, rectangle groupe masqué | +| `test_keyboard_shortcut_groups_selection` | `Ctrl+Shift+G` groupe la sélection | +| `test_keyboard_shortcut_ungroups_selection` | `Ctrl+Shift+U` dé-groupe le groupe sélectionné ; membres réaffichés | +| `test_undo_redo_move_resize_group` | Déplacement/redimensionnement du rectangle groupe (undo/redo) | +| `test_redo_move_resize_after_redo_group_keeps_same_uid` | Undo group + undo move, puis redo : même `uid` de groupe, move rejoué correctement | +| `test_group_boundary_proxies_assigned_on_creation` | Chaque frontière a un `proxy_uid` et un `proxy_layout` à la création | +| `test_proxies_visible_only_in_internal_view` | Proxies In/Out masqués à la racine, visibles en vue interne | +| `test_palette_exposes_group_ports_in_internal_view` | Catégorie `group_ports` (In/Out) visible uniquement en vue interne | +| `test_add_manual_boundary_port_creates_proxy` | Ajout port manuel `input` → proxy visible, label `In`, position sauvegardée | +| `test_add_second_manual_in_gets_unique_name` | Deuxième In nommé `In_1` ; labels affichés sur le rectangle parent | +| `test_undo_removes_manual_ports_before_ungrouping` | Undo port manuel puis undo groupage dans le bon ordre | +| `test_undo_connection_route_edit` | Undo/redo édition de route de connexion *(hors groupes — test de régression undo général)* | + +--- + +## `test_visual_group_persistence.py` + +Persistance `gui.groups` dans `project.yaml`. + +| Test | Cas couvert | +|------|-------------| +| `test_visual_group_from_dict_preserves_group_layout_with_member_layouts` | Désérialisation unitaire `VisualGroup.from_dict` (layout groupe + `member_layouts`) | +| `test_build_project_yaml_includes_gui_groups` | `build_project_yaml` écrit `gui.groups` avec frontières manuelles et layouts | +| `test_loader_restores_visual_groups_from_yaml` | Chargement projet avec groupes : uid, frontière manuelle, `member_layouts` | +| `test_loader_keeps_backward_compatibility_without_gui_groups` | Projet sans `gui.groups` → liste vide, pas d'erreur | + +--- + +## `test_nested_visual_groups.py` + +Groupes parents/enfants, visibilité multi-niveaux, suppression récursive. + +| Test | Cas couvert | +|------|-------------| +| `test_group_block_with_existing_subgroup` | Grouper un bloc + un sous-groupe existant : `child_group_uids`, `parent_uid` | +| `test_nested_group_visibility_at_root_and_inside_parent` | Masquage récursif à la racine ; enfant visible dans le parent | +| `test_group_selected_block_and_subgroup_from_context_menu` | Sélection bloc + `GroupItem` → groupage avec sous-groupe enfant | +| `test_ungroup_parent_promotes_child_groups` | Dé-grouper le parent promeut les enfants à la racine (`parent_uid = null`) | +| `test_create_subgroup_inside_parent_view` | Création sous-groupe depuis la vue interne du parent | +| `test_cross_group_connection_visible_at_root` | Connexion entre deux groupes frères visible à la racine | +| `test_cross_child_connection_visible_inside_parent` | Connexion entre enfants masquée à la racine, visible dans le parent | +| `test_delete_group_removes_nested_members_and_child_groups` | `delete_group` sur boucle imbriquée supprime blocs + sous-groupes ; undo restaure | + +--- + +## `test_group_orientation.py` + +Retournement visuel `Ctrl+R` sans modifier les blocs membres. + +| Test | Cas couvert | +|------|-------------| +| `test_ctrl_r_flips_group_display_without_touching_members` | Ports de frontière inversés (`normal` → `flipped`) ; orientation des membres inchangée | +| `test_flipped_group_wire_exits_away_from_anchor` | Routage fil externe : segment sort du rectangle dans le bon sens après flip | +| `test_ctrl_r_flips_proxy_display_without_touching_members` | Flip proxy In/Out en vue interne ; membre non retourné | + +--- + +## `test_group_boundary_labels.py` + +Libellés affichés sur les ports de frontière (`group_boundary_labels.py`). + +| Test | Cas couvert | +|------|-------------| +| `test_boundary_port_label_input_shows_external_source` | Frontière auto entrée → label = `display_as` du bloc source externe | +| `test_boundary_port_label_output_shows_external_destination` | Frontière auto sortie → label = `display_as` du bloc destination externe | +| `test_boundary_port_label_manual_defaults_to_in_or_out` | Port manuel sans câblage → `In` / `Out` | +| `test_boundary_port_label_manual_keeps_proxy_name_when_internally_wired` | Port manuel câblé en interne garde le label proxy (`In`) | + +--- + +## `test_diagram_clipboard.py` + +Copier-coller via `diagram_clipboard.py`. + +| Test | Cas couvert | +|------|-------------| +| `test_copy_paste_nested_group_preserves_hierarchy` | Copier boucle imbriquée → 3 groupes, 4 blocs, noms suffixés `_1`, hiérarchie préservée | +| `test_copy_paste_multiple_root_groups` | Copier deux groupes racine → deux racines collées avec noms uniques | +| `test_copy_paste_blocks_only` | Copier blocs + connexion sans groupe | +| `test_paste_nested_group_undo` | `undo_paste` annule duplication imbriquée | +| `test_paste_group_inside_parent_view` | Coller un sous-groupe dans la vue parent → `parent_uid` correct | +| `test_copy_paste_manual_in_out_proxies` | Copier/coller proxies In/Out avec positions et labels uniques | +| `test_copy_paste_proxy_with_member_block_preserves_internal_link` | Copier proxy + bloc membre → `linked_port_uid` recâblé sur le nouveau bloc | +| `test_paste_proxies_requires_internal_group_view` | Coller proxies hors vue interne refusé (`paste_clipboard_at` → `False`) | + +--- + +## `test_manual_boundary_wiring.py` + +Câblage partiel des frontières auto et manuelles, fils en pointillés, reconnexion côté par côté. + +| Test | Cas couvert | +|------|-------------| +| `test_delete_incomplete_internal_boundary_wire_keeps_proxy` | Suppression fil interne manuel (In) → proxy conservé, `linked_port_uid` vidé | +| `test_delete_incomplete_internal_boundary_wire_keeps_output_proxy` | Même scénario pour proxy **Out** (câblage `gain.output` ↔ GroupOut) | +| `test_delete_incomplete_external_boundary_wire_keeps_group_port` | Suppression fil externe après câblage partiel (In) → état externe vidé, interne conservé | +| `test_delete_incomplete_external_boundary_wire_keeps_output_group_port` | Même scénario pour proxy **Out** (externe sur `sum.input`) | +| `test_delete_completed_group_connection_keeps_proxy_and_shows_dashed_wire` | Suppression connexion complète (In) → fil interne `---`, pas de fil externe à la racine | +| `test_delete_completed_group_connection_keeps_output_proxy_and_dashed_wire` | Même scénario pour proxy **Out** | +| `test_delete_block_to_group_connection_keeps_internal_dashed_wire` | Suppression fil auto entrée à la racine → frontière orpheline + fil interne `---` | +| `test_delete_block_to_group_output_connection_keeps_internal_dashed_wire` | Même scénario pour frontière auto **sortie** | +| `test_delete_inside_group_keeps_external_reconnect_state` | Suppression fil interne auto (entrée) → fil externe `---` ; reconnexion externe puis interne | +| `test_delete_inside_group_keeps_external_reconnect_state_for_output` | Même scénario pour frontière auto **sortie** | +| `test_delete_internal_dashed_after_root_delete_keeps_nothing` | Suppression fil interne (In) après déconnexion racine → état entièrement vidé | +| `test_delete_internal_dashed_after_root_delete_keeps_nothing_for_output` | Même scénario pour proxy **Out** | +| `test_delete_auto_boundary_connection_keeps_proxy_in_group_view` | Suppression fil auto entrée → proxy visible + fil interne `---` | +| `test_delete_auto_output_boundary_connection_keeps_proxy_in_group_view` | Même scénario pour frontière auto **sortie** | +| `test_reconnect_block_to_group_via_border_port` | Reconnexion externe via port rectangle après suppression | +| `test_reconnect_after_delete_via_boundary_ports` | Reconnexion directe bloc↔bloc restaure `linked_connection_uid` | +| `test_cross_group_delete_keeps_border_ports_without_dashed_wires` | Suppression fil entre deux groupes → ports conservés, pas de fils `---` à la racine | +| `test_cross_group_reconnect_via_group_boundary_ports` | Reconnexion port sortie groupe A → port entrée groupe B | +| `test_auto_boundary_internal_dashed_after_cross_group_delete` | Après suppression cross-group → fil interne `---` visible dans le groupe source | +| `test_parent_group_shows_proxies_for_child_boundaries` | Proxies parent pour frontières enfant (y compris imbriqué) | +| `test_parent_with_only_child_groups_shows_proxies` | Groupe sans membres directs, uniquement enfants → tous les proxies visibles | +| `test_cross_child_group_delete_and_reconnect_inside_parent` | Suppression/reconnexion entre enfants depuis la vue parent | +| `test_delete_inside_nested_group_preserves_parent_external` | Suppression fil dans sous-groupe → état externe parent préservé + fil `---` racine | +| `test_reconnect_inside_nested_group_after_delete` | Reconnexion interne dans sous-groupe imbriqué | +| `test_reconnect_outside_nested_group_after_delete` | Reconnexion externe parent puis interne enfant (2 étapes) | +| `test_group_border_ports_keep_vertical_order_after_rebuild` | Ordre vertical des ports conservé après `_rebuild_group_boundary_ports` | +| `test_cross_group_delete_does_not_spawn_multiple_root_dashed_wires` | Pas de fils `---` parasites à la racine après suppression cross-group | +| `test_proxy_wires_to_child_group_border_inside_parent` | Proxy parent câblé vers port frontière d'un groupe enfant | + +--- + +## Commande de vérification + +```bash +python -m pytest tests/gui/test_visual_group*.py \ + tests/gui/test_group*.py \ + tests/gui/test_manual_boundary_wiring.py \ + tests/gui/test_diagram_clipboard.py \ + tests/gui/test_nested_visual_groups.py -v + +python -m pytest tests/ -q +``` + +### Sans affichage (offscreen / CI) + +```bash +QT_QPA_PLATFORM=offscreen python -m pytest tests/gui/test_visual_group*.py \ + tests/gui/test_group*.py \ + tests/gui/test_manual_boundary_wiring.py \ + tests/gui/test_diagram_clipboard.py \ + tests/gui/test_nested_visual_groups.py -v + +QT_QPA_PLATFORM=offscreen python -m pytest tests/ -q +``` diff --git a/examples/basics/nested_groups/gui/parameters.py b/examples/basics/nested_groups/gui/parameters.py new file mode 100644 index 0000000..fefe293 --- /dev/null +++ b/examples/basics/nested_groups/gui/parameters.py @@ -0,0 +1,14 @@ +import numpy as np +import control as ct + +# 3-state coupled dynamics to generate richer state trajectories for plotting. +A = np.array([ + [0.95, 0.10, 0.00], + [0.00, 0.97, 0.08], + [0.00, 0.00, 0.93], +]) +B = np.array([[1.0], [0.7], [0.4]]) +C = np.array([[1.0, 0.0, 0.0]]) + +K = ct.place(A, B, [0.86, 0.88, 0.90]) +G = np.linalg.inv(C @ np.linalg.inv(np.eye(3) - A + B @ K) @ B) diff --git a/examples/basics/nested_groups/gui/project.yaml b/examples/basics/nested_groups/gui/project.yaml new file mode 100644 index 0000000..40badea --- /dev/null +++ b/examples/basics/nested_groups/gui/project.yaml @@ -0,0 +1,355 @@ +schema_version: 1 +project: + name: gui +simulation: + dt: 0.02 + T: 2.0 + solver: fixed + external_module: parameters.py + logging: + - controller.outputs.u + - step.outputs.out + - system.outputs.y + - dist.outputs.out + plots: + - title: Reference vs output + mode: overlay + signals: + - step.outputs.out + - system.outputs.y + - title: Control and disturbance + mode: overlay + signals: + - controller.outputs.u + - dist.outputs.out +diagram: + blocks: + - name: step + category: sources + type: step + uid: 99f6d3c88fd148f9894da926d23db49d + parameters: + value_before: [[0.0]] + value_after: [[1.0]] + start_time: 0.5 + - name: dist + category: sources + type: step + uid: f8a5da1628a042c49ea65e9eb688e7e3 + parameters: + value_before: [[0.0]] + value_after: [[0.15]] + start_time: 1.0 + - name: gain_dist + category: operators + type: gain + uid: 936e4fe7174b44bda9b1d3d08bfdaf10 + parameters: + gain: 0.8 + multiplication: Element wise (K * u) + - name: sum_err + category: operators + type: sum + uid: 41e669bef8b34c5c82859ef228798300 + parameters: + signs: +- + - name: controller + category: controllers + type: state_feedback + uid: 3643a75d10754f21afb56a8a24844cdb + parameters: + K: '#K' + G: '#G' + - name: sum_plant + category: operators + type: sum + uid: db6d7d946a14478fa5fed05018e0a64a + parameters: + signs: ++ + - name: system + category: systems + type: linear_state_space + uid: 3f552e25cb9647bba4ce08cf897de600 + parameters: + A: '#A' + B: '#B' + C: '#C' + - name: monitor + category: operators + type: gain + uid: 5417b603712a41db8d3ef5d0df87b393 + parameters: + gain: 1.0 + multiplication: Element wise (K * u) + connections: + - name: c1 + ports: + - step.out + - sum_err.in1 + - name: c2 + ports: + - sum_err.out + - controller.r + - name: c3 + ports: + - controller.u + - sum_plant.in1 + - name: c4 + ports: + - dist.out + - gain_dist.in + - name: c5 + ports: + - gain_dist.out + - sum_plant.in2 + - name: c6 + ports: + - sum_plant.out + - system.u + - name: c7 + ports: + - system.x + - controller.x + - name: c8 + ports: + - system.y + - monitor.in + - name: c9 + ports: + - system.y + - sum_err.in2 +gui: + layout: + blocks: + step: + x: -360.0 + y: -20.0 + orientation: normal + width: 120.0 + height: 60.0 + monitor: + x: 155.0 + y: 30.0 + orientation: normal + width: 120.0 + height: 60.0 + connections: + c7: + route: [[368.0, 36.0], [376.0, 36.0], [376.0, 65.0], [-214.0, 65.0], [-214.0, + 46.5], [-206.0, 46.5]] + c9: + route: [[368.0, 72.0], [376.0, 72.0], [376.0, 90.0], [-214.0, 90.0], [-214.0, + 74.75], [-206.0, 74.75]] + groups: + - uid: df140fea1de3469c95fec651ffb8aa88 + name: Regulator + parent_uid: 3729168ddf894f62a7b4f1633b4b3014 + members: + - 41e669bef8b34c5c82859ef228798300 + - 3643a75d10754f21afb56a8a24844cdb + layout: + x: -200.0 + y: -10.0 + width: 213.0 + height: 113.0 + boundary_ports: + - uid: 08f79e2164134e2c8ab06cfcd05550a2 + direction: input + linked_port_uid: 41e669bef8b34c5c82859ef228798300:in1 + external_port_uid: '' + origin: auto + linked_connection_uid: 99f6d3c88fd148f9894da926d23db49d:out->41e669bef8b34c5c82859ef228798300:in1 + proxy_uid: 67d0670170ff4e3987328be0a602dd57 + proxy_layout: + x: -270.0 + y: -5.0 + - uid: 38c21c64aacb4610bdf4d0c7c0da5dba + direction: output + linked_port_uid: 3643a75d10754f21afb56a8a24844cdb:u + external_port_uid: '' + origin: auto + linked_connection_uid: 3643a75d10754f21afb56a8a24844cdb:u->db6d7d946a14478fa5fed05018e0a64a:in1 + proxy_uid: 5742f80d4bbd41e292ee8b3b70a14329 + proxy_layout: + x: 302.0 + y: 74.0 + - uid: ac6c2cd7c54a4ead86c817d54809eae3 + direction: input + linked_port_uid: 3643a75d10754f21afb56a8a24844cdb:x + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:x->3643a75d10754f21afb56a8a24844cdb:x + proxy_uid: 2d016beee74c4c26be65953288f9c879 + proxy_layout: + x: -40.0 + y: 60.0 + - uid: ac83d89c4ab54857b0efca7bc0fbf245 + direction: input + linked_port_uid: 41e669bef8b34c5c82859ef228798300:in2 + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:y->41e669bef8b34c5c82859ef228798300:in2 + proxy_uid: 0517465a5a4944449899eca849cbd0bd + proxy_layout: + x: -250.0 + y: 45.0 + child_group_uids: [] + member_layouts: + 41e669bef8b34c5c82859ef228798300: + x: -120.0 + y: -20.0 + orientation: normal + width: 120.0 + height: 60.0 + 3643a75d10754f21afb56a8a24844cdb: + x: 80.0 + y: -20.0 + orientation: normal + width: 120.0 + height: 60.0 + - uid: 00adc8fcee504a3cb1e38ce68b2e1abe + name: Plant + parent_uid: 3729168ddf894f62a7b4f1633b4b3014 + members: + - db6d7d946a14478fa5fed05018e0a64a + - 3f552e25cb9647bba4ce08cf897de600 + layout: + x: 170.0 + y: 0.0 + width: 183.0 + height: 108.0 + boundary_ports: + - uid: 5a91745e91ef4a7cad101ad596d27f2f + direction: input + linked_port_uid: db6d7d946a14478fa5fed05018e0a64a:in1 + external_port_uid: '' + origin: auto + linked_connection_uid: 3643a75d10754f21afb56a8a24844cdb:u->db6d7d946a14478fa5fed05018e0a64a:in1 + proxy_uid: d48a66209a8a4d01989c198ba6c0b017 + proxy_layout: + x: -355.0 + y: -75.0 + - uid: 092bf439152c4dc5b044bbb5c7b52cec + direction: input + linked_port_uid: db6d7d946a14478fa5fed05018e0a64a:in2 + external_port_uid: '' + origin: auto + linked_connection_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out->db6d7d946a14478fa5fed05018e0a64a:in2 + proxy_uid: fea1ec7b46d04a0fbbf9748935f13bfe + proxy_layout: + x: -330.0 + y: -20.0 + - uid: cd7a4d785d9847708bab0c993ae90a2d + direction: output + linked_port_uid: 3f552e25cb9647bba4ce08cf897de600:x + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:x->3643a75d10754f21afb56a8a24844cdb:x + proxy_uid: 4ed0bac4df17420eaddb138dde7d7486 + proxy_layout: + x: 225.0 + y: -65.0 + - uid: 4509861d585f48afbb33d26161148b27 + direction: output + linked_port_uid: 3f552e25cb9647bba4ce08cf897de600:y + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:y->5417b603712a41db8d3ef5d0df87b393:in + proxy_uid: d9c727c58bc94de0a3c8b131f937abac + proxy_layout: + x: 240.0 + y: -15.0 + child_group_uids: [] + member_layouts: + db6d7d946a14478fa5fed05018e0a64a: + x: -210.0 + y: -65.0 + orientation: normal + width: 120.0 + height: 60.0 + 3f552e25cb9647bba4ce08cf897de600: + x: -50.0 + y: -50.0 + orientation: normal + width: 120.0 + height: 60.0 + - uid: 3729168ddf894f62a7b4f1633b4b3014 + name: ControlLoop + parent_uid: null + members: [] + layout: + x: -170.0 + y: -15.0 + width: 236.0 + height: 111.0 + boundary_ports: + - uid: 638da3ee3cce4a7b92e8166c1e549a1f + direction: input + linked_port_uid: 41e669bef8b34c5c82859ef228798300:in1 + external_port_uid: '' + origin: auto + linked_connection_uid: 99f6d3c88fd148f9894da926d23db49d:out->41e669bef8b34c5c82859ef228798300:in1 + proxy_uid: b5fc602472d34016842a0c7639b2bc4b + proxy_layout: + x: -370.0 + y: -85.0 + - uid: c8796c93c6514e638cda4450cf860449 + direction: input + linked_port_uid: db6d7d946a14478fa5fed05018e0a64a:in2 + external_port_uid: '' + origin: auto + linked_connection_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out->db6d7d946a14478fa5fed05018e0a64a:in2 + proxy_uid: 4e7aa520fe664695a9a0820fc17a4b3b + proxy_layout: + x: 40.0 + y: 100.0 + - uid: 0cb3d8ef367845439a6f2ee922e19528 + direction: output + linked_port_uid: 3f552e25cb9647bba4ce08cf897de600:y + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:y->5417b603712a41db8d3ef5d0df87b393:in + proxy_uid: e28a0652c51f4f59a0d4e0360728e6bb + proxy_layout: + x: 420.0 + y: -5.0 + child_group_uids: + - df140fea1de3469c95fec651ffb8aa88 + - 00adc8fcee504a3cb1e38ce68b2e1abe + member_layouts: {} + - uid: f670518bf86645199e288e7828da1735 + name: Disturbance + parent_uid: null + members: + - f8a5da1628a042c49ea65e9eb688e7e3 + - 936e4fe7174b44bda9b1d3d08bfdaf10 + layout: + x: -400.0 + y: 285.0 + width: 368.0 + height: 108.0 + boundary_ports: + - uid: e0d819822a4048ca9f21b4c544c4c7ad + direction: output + linked_port_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out + external_port_uid: '' + origin: auto + linked_connection_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out->db6d7d946a14478fa5fed05018e0a64a:in2 + proxy_uid: bdd4c962e4a34698846ecf6fd6935847 + proxy_layout: + x: 302.0 + y: 74.0 + child_group_uids: [] + member_layouts: + f8a5da1628a042c49ea65e9eb688e7e3: + x: -360.0 + y: 180.0 + orientation: normal + width: 120.0 + height: 60.0 + 936e4fe7174b44bda9b1d3d08bfdaf10: + x: -160.0 + y: 180.0 + orientation: normal + width: 120.0 + height: 60.0 diff --git a/pySimBlocks/gui/diagram_clipboard.py b/pySimBlocks/gui/diagram_clipboard.py new file mode 100644 index 0000000..4ed4de4 --- /dev/null +++ b/pySimBlocks/gui/diagram_clipboard.py @@ -0,0 +1,694 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +import copy +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.models.block_instance import BlockInstance +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup +from pySimBlocks.gui.undo_redo.commands import ConnectionSnapshot + +if TYPE_CHECKING: + from pySimBlocks.gui.graphics.block_item import BlockItem + from pySimBlocks.gui.project_controller import ProjectController + + +@dataclass +class ClipboardBlock: + """Serializable block data for copy/paste.""" + + source_uid: str + category: str + block_type: str + name: str + parameters: dict[str, Any] + layout: dict[str, Any] + + +@dataclass +class ClipboardBoundaryPort: + """Serializable GroupIn/GroupOut proxy for copy/paste.""" + + source_uid: str + direction: str + label: str + layout: dict[str, Any] + linked_port_uid: str = "" + external_port_uid: str = "" + + +@dataclass +class DiagramClipboard: + """In-memory clipboard for diagram selections.""" + + blocks: list[ClipboardBlock] = field(default_factory=list) + connections: list[ConnectionSnapshot] = field(default_factory=list) + groups: list[dict[str, Any]] = field(default_factory=list) + root_group_uids: list[str] = field(default_factory=list) + boundary_ports: list[ClipboardBoundaryPort] = field(default_factory=list) + anchor_x: float = 0.0 + anchor_y: float = 0.0 + + +def clipboard_has_content(clipboard: DiagramClipboard | None) -> bool: + """Return whether a clipboard carries pasteable diagram data.""" + if clipboard is None: + return False + return bool(clipboard.blocks or clipboard.groups or clipboard.boundary_ports) + + +@dataclass +class PasteResult: + """Entities created by a paste operation (for undo).""" + + blocks: list[BlockInstance] = field(default_factory=list) + connections: list = field(default_factory=list) + group_uids: list[str] = field(default_factory=list) + boundary_ports: list[tuple[str, str]] = field(default_factory=list) + + +def _layout_anchor(layouts: list[dict[str, Any]]) -> tuple[float, float]: + if not layouts: + return 0.0, 0.0 + return ( + min(float(layout.get("x", 0.0)) for layout in layouts), + min(float(layout.get("y", 0.0)) for layout in layouts), + ) + + +def _offset_layout(layout: dict[str, Any], dx: float, dy: float) -> dict[str, Any]: + out = dict(layout) + out["x"] = float(out.get("x", 0.0)) + dx + out["y"] = float(out.get("y", 0.0)) + dy + return out + + +def _collect_subgroup_uids_postorder( + controller: ProjectController, + group_uid: str, +) -> list[str]: + """Return group uids in a subtree, children before parents.""" + group = controller.project_state.get_visual_group(group_uid) + if group is None: + return [] + ordered: list[str] = [] + for child_uid in group.child_group_uids: + ordered.extend(_collect_subgroup_uids_postorder(controller, child_uid)) + ordered.append(group_uid) + return ordered + + +def _root_groups_in_selection( + controller: ProjectController, + group_uids: set[str], +) -> list[str]: + """Return selected groups whose parent is outside the selection.""" + roots: list[str] = [] + for uid in group_uids: + group = controller.project_state.get_visual_group(uid) + if group is None: + continue + if group.parent_uid not in group_uids: + roots.append(uid) + return roots + + +def _ordered_group_uids( + controller: ProjectController, + root_group_uids: list[str], +) -> list[str]: + """Collect subtrees from several roots without duplicates (post-order).""" + ordered: list[str] = [] + seen: set[str] = set() + for root_uid in root_group_uids: + for group_uid in _collect_subgroup_uids_postorder(controller, root_uid): + if group_uid in seen: + continue + ordered.append(group_uid) + seen.add(group_uid) + return ordered + + +def _capture_internal_connections( + controller: ProjectController, + member_uids: set[str], +) -> list[ConnectionSnapshot]: + connections: list[ConnectionSnapshot] = [] + for connection in controller.project_state.connections: + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + if src_uid in member_uids and dst_uid in member_uids: + connections.append(controller._capture_connection_snapshot(connection)) + return connections + + +def _block_uids_in_group_templates(templates: list[dict[str, Any]]) -> set[str]: + return { + uid + for template in templates + for uid in template.get("members", []) + } + + +def capture_blocks_clipboard( + controller: ProjectController, + block_items: list[BlockItem], +) -> DiagramClipboard | None: + """Capture selected blocks and their internal connections.""" + if not block_items: + return None + + member_uids = {item.instance.uid for item in block_items} + blocks: list[ClipboardBlock] = [] + layouts: list[dict[str, Any]] = [] + + for item in block_items: + instance = item.instance + layout = controller._capture_block_layout(instance) + layouts.append(layout) + blocks.append( + ClipboardBlock( + source_uid=instance.uid, + category=instance.meta.category, + block_type=instance.meta.type, + name=instance.name, + parameters=instance.parameters.copy(), + layout=layout, + ) + ) + + anchor_x, anchor_y = _layout_anchor(layouts) + return DiagramClipboard( + blocks=blocks, + connections=_capture_internal_connections(controller, member_uids), + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def capture_groups_clipboard( + controller: ProjectController, + selected_group_uids: list[str], +) -> DiagramClipboard | None: + """Capture one or more visual groups with nested children and internal wiring.""" + if not selected_group_uids: + return None + + selected_set = set(selected_group_uids) + root_group_uids = _root_groups_in_selection(controller, selected_set) + ordered_group_uids = _ordered_group_uids(controller, root_group_uids) + if not ordered_group_uids: + return None + + blocks: list[ClipboardBlock] = [] + seen_block_uids: set[str] = set() + anchor_layouts: list[dict[str, Any]] = [] + + for group_uid in ordered_group_uids: + group = controller.project_state.get_visual_group(group_uid) + if group is None: + continue + for member_uid in group.members: + if member_uid in seen_block_uids: + continue + block = controller._find_block_by_uid(member_uid) + if block is None: + continue + layout = dict(group.member_layouts.get(member_uid, {})) + if not layout: + layout = controller._capture_block_layout(block) + seen_block_uids.add(member_uid) + blocks.append( + ClipboardBlock( + source_uid=block.uid, + category=block.meta.category, + block_type=block.meta.type, + name=block.name, + parameters=block.parameters.copy(), + layout=layout, + ) + ) + + for root_uid in root_group_uids: + root = controller.project_state.get_visual_group(root_uid) + if root is None: + continue + if root.layout: + anchor_layouts.append(dict(root.layout)) + else: + member_layouts = [ + dict(root.member_layouts.get(uid, {})) + for uid in root.members + if uid in root.member_layouts + ] + if member_layouts: + anchor_layouts.extend(member_layouts) + + if not blocks and not ordered_group_uids: + return None + + anchor_x, anchor_y = _layout_anchor(anchor_layouts) + groups = [ + copy.deepcopy(controller.project_state.get_visual_group(group_uid).to_dict()) + for group_uid in ordered_group_uids + if controller.project_state.get_visual_group(group_uid) is not None + ] + + return DiagramClipboard( + blocks=blocks, + connections=_capture_internal_connections(controller, seen_block_uids), + groups=groups, + root_group_uids=root_group_uids, + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def capture_group_clipboard( + controller: ProjectController, + group: VisualGroup, +) -> DiagramClipboard | None: + """Capture a visual group, its descendants, and internal connections.""" + return capture_groups_clipboard(controller, [group.uid]) + + +def _capture_mixed_selection_clipboard( + controller: ProjectController, + selected_group_uids: list[str], + block_items: list[BlockItem], +) -> DiagramClipboard | None: + """Capture selected groups (full subtrees) plus standalone blocks.""" + group_clipboard = capture_groups_clipboard(controller, selected_group_uids) + selected_set = set(selected_group_uids) + grouped_block_uids: set[str] = set() + for group_uid in selected_set: + group = controller.project_state.get_visual_group(group_uid) + if group is None: + continue + grouped_block_uids.update(controller._group_content_uids_for_group(group)) + + standalone_items = [ + item for item in block_items if item.instance.uid not in grouped_block_uids + ] + if group_clipboard is None and not standalone_items: + return None + if group_clipboard is None: + return capture_blocks_clipboard(controller, standalone_items) + + if not standalone_items: + return group_clipboard + + block_clipboard = capture_blocks_clipboard(controller, standalone_items) + if block_clipboard is None: + return group_clipboard + + all_member_uids = { + block.source_uid for block in group_clipboard.blocks + block_clipboard.blocks + } + anchor_layouts = [ + dict(block.layout) + for block in block_clipboard.blocks + ] + for root_uid in group_clipboard.root_group_uids: + root = controller.project_state.get_visual_group(root_uid) + if root is not None and root.layout: + anchor_layouts.append(dict(root.layout)) + + anchor_x, anchor_y = _layout_anchor(anchor_layouts) + return DiagramClipboard( + blocks=group_clipboard.blocks + block_clipboard.blocks, + connections=_capture_internal_connections(controller, all_member_uids), + groups=group_clipboard.groups, + root_group_uids=group_clipboard.root_group_uids, + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def _selected_proxy_items(selected) -> list: + """Return unique GroupProxyItem instances from a scene selection.""" + from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem + + proxies: list[GroupProxyItem] = [] + seen: set[str] = set() + for item in selected: + if isinstance(item, GroupProxyPortItem): + item = item.parent_proxy + if not isinstance(item, GroupProxyItem): + continue + if item.boundary.uid in seen: + continue + seen.add(item.boundary.uid) + proxies.append(item) + return proxies + + +def capture_proxies_clipboard( + controller: ProjectController, + proxy_items: list, +) -> DiagramClipboard | None: + """Capture selected GroupIn/GroupOut proxies from the active internal view.""" + if not proxy_items or controller.view.current_view_group_uid is None: + return None + + boundary_ports: list[ClipboardBoundaryPort] = [] + layouts: list[dict[str, Any]] = [] + for proxy in proxy_items: + boundary = proxy.boundary + layout = dict(boundary.proxy_layout) if boundary.proxy_layout else { + "x": float(proxy.pos().x()), + "y": float(proxy.pos().y()), + } + layouts.append(layout) + linked_port_uid = "" + external_port_uid = "" + if boundary.origin == "manual" and not boundary.linked_connection_uid: + linked_port_uid = boundary.linked_port_uid + external_port_uid = boundary.external_port_uid + boundary_ports.append( + ClipboardBoundaryPort( + source_uid=boundary.uid, + direction=boundary.direction, + label=boundary.label, + layout=layout, + linked_port_uid=linked_port_uid, + external_port_uid=external_port_uid, + ) + ) + + anchor_x, anchor_y = _layout_anchor(layouts) + return DiagramClipboard( + boundary_ports=boundary_ports, + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def capture_selection_clipboard(controller: ProjectController) -> DiagramClipboard | None: + """Capture the current diagram selection for copy.""" + from pySimBlocks.gui.graphics.block_item import BlockItem + from pySimBlocks.gui.graphics.group_item import GroupItem + + selected = controller.view.diagram_scene.selectedItems() + groups = [item for item in selected if isinstance(item, GroupItem)] + blocks = [item for item in selected if isinstance(item, BlockItem)] + proxies = _selected_proxy_items(selected) + + if groups and blocks: + clipboard = _capture_mixed_selection_clipboard( + controller, + [item.group.uid for item in groups], + blocks, + ) + elif groups: + clipboard = capture_groups_clipboard(controller, [item.group.uid for item in groups]) + elif blocks: + clipboard = capture_blocks_clipboard(controller, blocks) + elif proxies: + clipboard = capture_proxies_clipboard(controller, proxies) + else: + return None + + if clipboard is None: + return None + if not proxies: + return clipboard + + proxy_clipboard = capture_proxies_clipboard(controller, proxies) + if proxy_clipboard is None: + return clipboard + + anchor_layouts = [ + dict(port.layout) for port in proxy_clipboard.boundary_ports + ] + for block in clipboard.blocks: + anchor_layouts.append(dict(block.layout)) + for root_uid in clipboard.root_group_uids: + root = controller.project_state.get_visual_group(root_uid) + if root is not None and root.layout: + anchor_layouts.append(dict(root.layout)) + + anchor_x, anchor_y = _layout_anchor(anchor_layouts) + clipboard.boundary_ports = proxy_clipboard.boundary_ports + clipboard.anchor_x = anchor_x + clipboard.anchor_y = anchor_y + return clipboard + + +def _remap_connection( + snapshot: ConnectionSnapshot, + uid_map: dict[str, str], + dx: float = 0.0, + dy: float = 0.0, +) -> ConnectionSnapshot | None: + if snapshot.src_block_uid not in uid_map or snapshot.dst_block_uid not in uid_map: + return None + points: list[QPointF] | None = None + if snapshot.points: + points = [QPointF(point.x() + dx, point.y() + dy) for point in snapshot.points] + return ConnectionSnapshot( + src_block_uid=uid_map[snapshot.src_block_uid], + src_port_name=snapshot.src_port_name, + dst_block_uid=uid_map[snapshot.dst_block_uid], + dst_port_name=snapshot.dst_port_name, + points=points, + ) + + +def _duplicate_group( + controller: ProjectController, + template: dict[str, Any], + uid_map: dict[str, str], + gid_map: dict[str, str], + dx: float, + dy: float, +) -> VisualGroup: + group = VisualGroup.from_dict(copy.deepcopy(template)) + group.uid = uuid.uuid4().hex + group.name = controller._make_unique_group_name(group.name) + group.parent_uid = None + group.members = [uid_map[uid] for uid in template.get("members", []) if uid in uid_map] + group.child_group_uids = [ + gid_map[uid] for uid in template.get("child_group_uids", []) if uid in gid_map + ] + + member_layouts: dict[str, dict[str, Any]] = {} + for old_uid, layout in (template.get("member_layouts") or {}).items(): + if old_uid not in uid_map: + continue + member_layouts[uid_map[old_uid]] = dict(layout) + group.member_layouts = member_layouts + + if group.layout: + group.layout = _offset_layout(dict(group.layout), dx, dy) + + new_boundaries: list[BoundaryPort] = [] + for boundary in group.boundary_ports: + remapped = BoundaryPort( + uid=uuid.uuid4().hex, + direction=boundary.direction, + linked_port_uid="", + external_port_uid="", + origin=boundary.origin, + linked_connection_uid="", + label=boundary.label, + proxy_uid=uuid.uuid4().hex, + proxy_layout=dict(boundary.proxy_layout) if boundary.proxy_layout else {}, + ) + if boundary.linked_port_uid and ":" in boundary.linked_port_uid: + old_uid, port_name = boundary.linked_port_uid.split(":", 1) + if old_uid in uid_map: + remapped.linked_port_uid = f"{uid_map[old_uid]}:{port_name}" + new_boundaries.append(remapped) + group.boundary_ports = new_boundaries + + controller.project_state.visual_groups.append(group) + controller._rebuild_group_boundary_ports(group) + controller.ensure_group_boundary_proxies(group) + return group + + +def _paste_boundary_label( + controller: ProjectController, + group: VisualGroup, + port_data: ClipboardBoundaryPort, +) -> str: + """Choose a unique label for a pasted GroupIn/GroupOut.""" + preferred = port_data.label.strip() or controller._proxy_default_label(port_data.direction) + used: set[str] = set() + for port in group.boundary_ports: + if port.origin != "manual" or port.direction != port_data.direction: + continue + name = port.label.strip() or controller._proxy_default_label(port.direction) + used.add(name) + if preferred not in used: + return preferred + index = 1 + while f"{preferred}_{index}" in used: + index += 1 + return f"{preferred}_{index}" + + +def _paste_boundary_ports( + controller: ProjectController, + clipboard: DiagramClipboard, + origin: QPointF, + uid_map: dict[str, str], + *, + parent_group_uid: str | None, + result: PasteResult, +) -> None: + """Create GroupIn/GroupOut proxies from clipboard boundary ports.""" + if not clipboard.boundary_ports or parent_group_uid is None: + return + + group = controller.project_state.get_visual_group(parent_group_uid) + if group is None: + return + + dx = float(origin.x()) - clipboard.anchor_x + dy = float(origin.y()) - clipboard.anchor_y + for port_data in clipboard.boundary_ports: + layout = _offset_layout(dict(port_data.layout), dx, dy) + boundary = BoundaryPort( + uid=uuid.uuid4().hex, + direction=port_data.direction, + origin="manual", + label=_paste_boundary_label(controller, group, port_data), + proxy_uid=uuid.uuid4().hex, + proxy_layout=layout, + ) + if port_data.linked_port_uid and ":" in port_data.linked_port_uid: + old_uid, port_name = port_data.linked_port_uid.split(":", 1) + if old_uid in uid_map: + boundary.linked_port_uid = f"{uid_map[old_uid]}:{port_name}" + if port_data.external_port_uid and ":" in port_data.external_port_uid: + old_uid, port_name = port_data.external_port_uid.split(":", 1) + if old_uid in uid_map: + boundary.external_port_uid = f"{uid_map[old_uid]}:{port_name}" + controller._add_manual_boundary_port(parent_group_uid, boundary) + result.boundary_ports.append((parent_group_uid, boundary.uid)) + + +def paste_clipboard( + controller: ProjectController, + clipboard: DiagramClipboard, + origin: QPointF, + *, + parent_group_uid: str | None = None, +) -> PasteResult: + """Paste clipboard contents at ``origin`` without pushing undo.""" + result = PasteResult() + if not clipboard_has_content(clipboard): + return result + + dx = float(origin.x()) - clipboard.anchor_x + dy = float(origin.y()) - clipboard.anchor_y + uid_map: dict[str, str] = {} + has_groups = bool(clipboard.groups) + grouped_source_uids = _block_uids_in_group_templates(clipboard.groups) + + for block_data in clipboard.blocks: + meta = controller.resolve_block_meta(block_data.category, block_data.block_type) + block = BlockInstance(meta) + block.parameters = block_data.parameters.copy() + block.name = block_data.name + if has_groups and block_data.source_uid in grouped_source_uids: + layout = dict(block_data.layout) + else: + layout = _offset_layout(block_data.layout, dx, dy) + created = controller._add_block(block, layout) + uid_map[block_data.source_uid] = created.uid + result.blocks.append(created) + if ( + parent_group_uid is not None + and (not has_groups or block_data.source_uid not in grouped_source_uids) + ): + controller._add_member_to_group(parent_group_uid, created.uid, layout) + + connection_offset_dx = 0.0 if has_groups else dx + connection_offset_dy = 0.0 if has_groups else dy + for snapshot in clipboard.connections: + remapped = _remap_connection( + snapshot, + uid_map, + connection_offset_dx, + connection_offset_dy, + ) + if remapped is None: + continue + connection = controller._add_connection_from_snapshot(remapped) + if connection is not None: + result.connections.append(connection) + + gid_map: dict[str, str] = {} + created_groups: list[tuple[dict[str, Any], VisualGroup]] = [] + for template in clipboard.groups: + old_uid = str(template["uid"]) + root_offset_dx = dx if old_uid in clipboard.root_group_uids else 0.0 + root_offset_dy = dy if old_uid in clipboard.root_group_uids else 0.0 + group = _duplicate_group( + controller, + template, + uid_map, + gid_map, + root_offset_dx, + root_offset_dy, + ) + gid_map[old_uid] = group.uid + created_groups.append((template, group)) + result.group_uids.append(group.uid) + + for template, group in created_groups: + old_uid = str(template["uid"]) + parent_old = template.get("parent_uid") + if parent_old and parent_old in gid_map: + group.parent_uid = gid_map[str(parent_old)] + controller._attach_group_to_parent(group) + elif old_uid in clipboard.root_group_uids: + group.parent_uid = parent_group_uid + if parent_group_uid is not None: + controller._attach_group_to_parent(group) + + target_group_uid = parent_group_uid + if target_group_uid is None and clipboard.boundary_ports: + target_group_uid = controller.view.current_view_group_uid + + _paste_boundary_ports( + controller, + clipboard, + origin, + uid_map, + parent_group_uid=target_group_uid, + result=result, + ) + + controller.view.refresh_visual_groups() + return result + + +def undo_paste(controller: ProjectController, result: PasteResult) -> None: + """Remove entities created by a paste operation.""" + for group_uid, boundary_uid in reversed(result.boundary_ports): + controller._remove_boundary_port(group_uid, boundary_uid) + + for group_uid in reversed(result.group_uids): + if group_uid in controller.view.view_stack: + controller.view.navigate_out_of_group(group_uid) + controller._remove_visual_group(group_uid) + + for connection in list(result.connections): + controller._remove_connection(connection, refresh_boundaries=False) + + for block in list(result.blocks): + controller._remove_block(block) + + controller.view.refresh_visual_groups() diff --git a/pySimBlocks/gui/dialogs/plot_dialog.py b/pySimBlocks/gui/dialogs/plot_dialog.py index f2adcc4..e202500 100644 --- a/pySimBlocks/gui/dialogs/plot_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_dialog.py @@ -690,14 +690,14 @@ def _select_manual_plot(self, index: int) -> None: if index < 0 or index >= len(self._manual.selections): return if index == self._manual.active: - self._update_preview_plot() + self._refresh_manual_panel_highlights() return self._save_active_manual_selection() self._save_active_manual_title() self._manual.active = index self._load_active_manual_title() self._load_manual_selection_to_tree(self._manual.selections[index]) - self._update_preview_plot() + self._refresh_manual_panel_highlights() def _save_active_manual_selection(self) -> None: """Persist the signal tree checks into the active manual plot.""" @@ -986,6 +986,16 @@ def _draw_manual_panel( self._axis_to_panel_key[id(ax)] = key self._highlight_manual_axis(ax, key == f"manual::{self._manual.active}") + @staticmethod + def _manual_panel_index_from_key(key: str) -> int: + """Return manual panel index encoded in a preview panel key.""" + if key.startswith("manual::"): + try: + return int(key.split("::", 1)[1]) + except ValueError: + pass + return 0 + def _render_manual_plots_preview(self) -> None: """Render all manual plot panels in a 2-column grid (last odd spans full width).""" self._refresh_subplot_filter([], keep_current=False) @@ -1026,7 +1036,8 @@ def _render_manual_plots_preview(self) -> None: if len(panels) == 1: title, key, series = panels[0] ax = self.figure.add_subplot(111) - self._draw_manual_panel(ax, time, title, key, series, 0) + panel_idx = self._manual_panel_index_from_key(key) + self._draw_manual_panel(ax, time, title, key, series, panel_idx) self._finalize_layout() return @@ -1037,7 +1048,8 @@ def _render_manual_plots_preview(self) -> None: ax = self.figure.add_subplot(gs[i // 2, :]) else: ax = self.figure.add_subplot(gs[i // 2, i % 2]) - self._draw_manual_panel(ax, time, title, key, series, i) + panel_idx = self._manual_panel_index_from_key(key) + self._draw_manual_panel(ax, time, title, key, series, panel_idx) self._finalize_layout() def _highlight_manual_axis(self, ax, selected: bool) -> None: @@ -1048,6 +1060,18 @@ def _highlight_manual_axis(self, ax, selected: bool) -> None: spine.set_linewidth(width) spine.set_edgecolor(color) + def _refresh_manual_panel_highlights(self) -> None: + """Update active-panel borders without redrawing plot data (keeps zoom).""" + if not self._uses_manual_layout() or not self.figure.axes: + return + active_key = f"manual::{self._manual.active}" + for ax in self.figure.axes: + key = self._axis_to_panel_key.get(id(ax)) + if key is None or not key.startswith("manual::"): + continue + self._highlight_manual_axis(ax, key == active_key) + self.canvas.draw_idle() + def _refresh_subplot_filter(self, panels: list[tuple[str, str, list[tuple[str, np.ndarray]]]], keep_current: bool): """Rebuild subplot check-list from panel descriptors.""" current_enabled = self._selected_subplot_keys() if keep_current else set() diff --git a/pySimBlocks/gui/graphics/__init__.py b/pySimBlocks/gui/graphics/__init__.py index 621efd5..180b5c9 100644 --- a/pySimBlocks/gui/graphics/__init__.py +++ b/pySimBlocks/gui/graphics/__init__.py @@ -20,10 +20,16 @@ from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem +from pySimBlocks.gui.graphics.group_item import GroupItem, GroupBoundaryPortItem +from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem from pySimBlocks.gui.graphics.port_item import PortItem __all__ = [ "BlockItem", "ConnectionItem", - "PortItem" + "GroupItem", + "GroupBoundaryPortItem", + "GroupProxyItem", + "GroupProxyPortItem", + "PortItem", ] diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py index 7ea6165..667341f 100644 --- a/pySimBlocks/gui/graphics/block_item.py +++ b/pySimBlocks/gui/graphics/block_item.py @@ -311,7 +311,11 @@ def mouseReleaseEvent(self, event): if start_pos is None or start_rect is None: return - if start_pos != end_pos or start_rect != end_rect: + moved = start_pos != end_pos or start_rect != end_rect + if moved and self.view.try_drop_block_onto_group(self): + return + + if moved: self.view.project_controller.execute_move_resize_block( self.instance, start_pos, diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 42c5f18..1024790 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -18,7 +18,7 @@ # Authors: see Authors.txt # ****************************************************************************** -from PySide6.QtCore import Qt, QPointF +from PySide6.QtCore import Qt, QPointF, QRectF from PySide6.QtGui import QPen, QPainterPath, QPainterPathStroker from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem @@ -26,6 +26,19 @@ from pySimBlocks.gui.models.connection_instance import ConnectionInstance +def _endpoint_view(endpoint): + if isinstance(endpoint, PortItem): + return endpoint.parent_block.view + from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyPortItem + from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem + + if isinstance(endpoint, GroupProxyPortItem): + return endpoint.parent_proxy.view + if isinstance(endpoint, GroupBoundaryPortItem): + return endpoint.parent_group.view + raise TypeError(f"Unsupported wire endpoint: {type(endpoint)!r}") + + class OrthogonalRoute: """Store routed connection points and the segment being dragged. @@ -62,8 +75,10 @@ class ConnectionItem(QGraphicsPathItem): OFFSET = 8 MARGIN = 12 DETOUR = 8 - PICK_TOL = 6 + PICK_TOL = 10 GRID = 5 + AXIS_EPS = 0.5 + JOG_EPS = 8.0 def __init__(self, src_port: PortItem | None, @@ -93,24 +108,29 @@ def __init__(self, self._valid_port = src_port if src_port is not None else dst_port self.is_manual: bool = False self.route: OrthogonalRoute | None = None + self._route_drag_active = False + self._route_points_before_drag: list[QPointF] | None = None + self._manual_src_redirected: bool | None = None + self._manual_dst_redirected: bool | None = None + self._manual_view_group_uid: str | None = None if points and len(points) >= 2: self.apply_manual_route(points) - t = self._valid_port.parent_block.view.theme + t = _endpoint_view(self._valid_port) if self.is_temporary: self.setFlag(QGraphicsItem.ItemIsSelectable, False) self.setAcceptedMouseButtons(Qt.NoButton) - pen = QPen(t.wire, 3, Qt.DashLine) + pen = QPen(t.theme.wire, 3, Qt.DashLine) else: self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setAcceptedMouseButtons(Qt.LeftButton) - pen = QPen(t.wire, 3, Qt.SolidLine) + pen = QPen(t.theme.wire, 3, Qt.SolidLine) self.setPen(pen) - self.setZValue(1) + self.setZValue(2) self.update_position() @@ -122,16 +142,31 @@ def update_position(self): if self.is_temporary: return - p1 = self.src_port.connection_anchor() - p2 = self.dst_port.connection_anchor() - if self.is_manual and self.route and len(self.route.points) >= 2: - self.route.points[0] = p1 - self.route.points[-1] = p2 - self._apply_route(self.route.points) + view = self.src_port.parent_block.view + p1 = view.connection_anchor_for_port_item(self.src_port) + p2 = view.connection_anchor_for_port_item(self.dst_port) + + if self._route_drag_active: + if self.route and len(self.route.points) >= 2: + self.route.points[0] = p1 + self.route.points[-1] = p2 + self._apply_route(self.route.points, simplify=False) return + if self.is_manual and self.route and len(self.route.points) >= 2: + if self._manual_anchor_context_matches(): + self.route.points[0] = p1 + self.route.points[-1] = p2 + self._apply_route(self.route.points, simplify=False) + return + self.is_manual = False + pts = self._compute_auto_route(p1, p2) self.route = OrthogonalRoute(pts) + self.is_manual = False + self._manual_src_redirected = None + self._manual_dst_redirected = None + self._manual_view_group_uid = None self._apply_route(self.route.points) def update_temp_position(self, scene_pos: QPointF): @@ -141,8 +176,7 @@ def update_temp_position(self, scene_pos: QPointF): scene_pos: Current mouse position in scene coordinates. """ p1 = self._valid_port.connection_anchor() - pts = [p1, scene_pos] - self._apply_route(pts) + self._apply_route([p1, scene_pos], simplify=False) def apply_manual_route(self, points: list[QPointF]): """Apply a persisted manual route to the connection. @@ -152,12 +186,16 @@ def apply_manual_route(self, points: list[QPointF]): """ self.route = OrthogonalRoute(points) self.is_manual = True + self._capture_manual_anchor_context() self._apply_route(self.route.points) def invalidate_manual_route(self): """Discard any manual route so the next update recomputes it.""" self.is_manual = False self.route = None + self._manual_src_redirected = None + self._manual_dst_redirected = None + self._manual_view_group_uid = None def segment_at(self, scene_pos: QPointF) -> int | None: """Return the route segment index located near the given scene point. @@ -175,14 +213,14 @@ def segment_at(self, scene_pos: QPointF) -> int | None: for i in range(len(pts) - 1): a, b = pts[i], pts[i + 1] - if a.x() == b.x(): # vertical + if abs(a.x() - b.x()) < self.AXIS_EPS: # vertical if abs(scene_pos.x() - a.x()) < self.PICK_TOL \ - and min(a.y(), b.y()) <= scene_pos.y() <= max(a.y(), b.y()): + and min(a.y(), b.y()) - self.PICK_TOL <= scene_pos.y() <= max(a.y(), b.y()) + self.PICK_TOL: return i - if a.y() == b.y(): # horizontal + elif abs(a.y() - b.y()) < self.AXIS_EPS: # horizontal if abs(scene_pos.y() - a.y()) < self.PICK_TOL \ - and min(a.x(), b.x()) <= scene_pos.x() <= max(a.x(), b.x()): + and min(a.x(), b.x()) - self.PICK_TOL <= scene_pos.x() <= max(a.x(), b.x()) + self.PICK_TOL: return i return None @@ -193,95 +231,159 @@ def shape(self): Stroke path used for hit testing. """ stroker = QPainterPathStroker() - stroker.setWidth(6) + stroker.setWidth(12) return stroker.createStroke(self.path()) def mousePressEvent(self, event): - """Start manual segment dragging when pressing a routed segment. - - Args: - event: Qt mouse-press event. - """ - idx = self.segment_at(event.scenePos()) - if idx is not None: - self.route.dragged_index = idx - self.is_manual = True - event.accept() - else: - super().mousePressEvent(event) + """Start manual segment dragging with the left mouse button.""" + if event.button() == Qt.LeftButton: + idx = self.segment_at(event.scenePos()) + if idx is not None: + if self.route is None: + self.update_position() + if self.route is None: + super().mousePressEvent(event) + return + self._route_points_before_drag = [ + QPointF(point) for point in self.route.points + ] + self.route.dragged_index = idx + self.is_manual = True + self._route_drag_active = True + self.grabMouse() + event.accept() + return + super().mousePressEvent(event) def mouseMoveEvent(self, event): - """Move the selected orthogonal segment during manual route editing. - - Args: - event: Qt mouse-move event. - """ + """Move the selected orthogonal segment during manual route editing.""" if not self.route or self.route.dragged_index is None: + super().mouseMoveEvent(event) + return + + if not (event.buttons() & Qt.LeftButton): return i = self.route.dragged_index - a = self.route.points[i] - b = self.route.points[i + 1] + points = self.route.points + if i < 0 or i + 1 >= len(points): + self.route.dragged_index = None + self._route_drag_active = False + self.ungrabMouse() + return + + a = points[i] + b = points[i + 1] pos = event.scenePos() - if a.x() == b.x(): # vertical segment + if abs(a.x() - b.x()) < self.AXIS_EPS: # vertical segment x = self._snap(pos.x()) - self.route.points[i] = QPointF(x, a.y()) - self.route.points[i + 1] = QPointF(x, b.y()) + points[i] = QPointF(x, a.y()) + points[i + 1] = QPointF(x, b.y()) - elif a.y() == b.y(): # horizontal segment + elif abs(a.y() - b.y()) < self.AXIS_EPS: # horizontal segment y = self._snap(pos.y()) - self.route.points[i] = QPointF(a.x(), y) - self.route.points[i + 1] = QPointF(b.x(), y) + points[i] = QPointF(a.x(), y) + points[i + 1] = QPointF(b.x(), y) - self._apply_route(self.route.points) + else: + return - def mouseReleaseEvent(self, event): - """Finish manual segment dragging. + self._apply_route(points, simplify=False) - Args: - event: Qt mouse-release event. - """ + def mouseReleaseEvent(self, event): + """Finish manual segment dragging.""" + was_dragging = self._route_drag_active if self.route: self.route.dragged_index = None + self._route_drag_active = False + if was_dragging: + self.ungrabMouse() super().mouseReleaseEvent(event) + if was_dragging and event.button() == Qt.LeftButton and self.route is not None: + self._apply_route(self.route.points) + self._capture_manual_anchor_context() + view = self.src_port.parent_block.view + new_points = [QPointF(point) for point in self.route.points] + view.on_connection_route_edited( + self, + self._route_points_before_drag, + new_points, + ) + self._route_points_before_drag = None # -------------------------------------------------------------------------- # Private Methods # -------------------------------------------------------------------------- + def _anchor_redirected(self, port_item: PortItem) -> bool: + """Return whether the current view routes this port through a group border or proxy.""" + view = port_item.parent_block.view + redirected = view.connection_anchor_for_port_item(port_item) + direct = port_item.connection_anchor() + return ( + abs(redirected.x() - direct.x()) > 0.5 + or abs(redirected.y() - direct.y()) > 0.5 + ) + + def _capture_manual_anchor_context(self) -> None: + """Remember view level and anchor redirection when the route was edited.""" + view = self.src_port.parent_block.view + self._manual_view_group_uid = view.current_view_group_uid + self._manual_src_redirected = self._anchor_redirected(self.src_port) + self._manual_dst_redirected = self._anchor_redirected(self.dst_port) + + def _manual_anchor_context_matches(self) -> bool: + """Return whether the current view matches the one used when the route was edited.""" + view = self.src_port.parent_block.view + if view.current_view_group_uid != self._manual_view_group_uid: + return False + src_redirected = self._anchor_redirected(self.src_port) + dst_redirected = self._anchor_redirected(self.dst_port) + if self._manual_src_redirected is None or self._manual_dst_redirected is None: + return not src_redirected and not dst_redirected + return ( + self._manual_src_redirected == src_redirected + and self._manual_dst_redirected == dst_redirected + ) + def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: """Compute an orthogonal route between two port anchors.""" - src_block = self.src_port.parent_block - dst_block = self.dst_port.parent_block - - # Use the visual block rect (not selection handle hit area) for routing. - src_rect = src_block.mapRectToScene(src_block.rect()) - dst_rect = dst_block.mapRectToScene(dst_block.rect()) + src_rect = self._routing_rect_for_port(self.src_port) + dst_rect = self._routing_rect_for_port(self.dst_port) - src_out_sign = 1 if not self.src_port.is_on_left_side else -1 - dst_in_sign = -1 if self.dst_port.is_on_left_side else 1 + src_out_sign = self._wire_side_sign(p1, src_rect) + dst_in_sign = self._wire_side_sign(p2, dst_rect) p1_out = QPointF(p1.x() + src_out_sign * self.OFFSET, p1.y()) p2_in = QPointF(p2.x() + dst_in_sign * self.OFFSET, p2.y()) - same_block = src_block is dst_block + same_block = self.src_port.parent_block is self.dst_port.parent_block u_turn = ((p2_in.x() - p1_out.x()) * src_out_sign) < 0 is_feedback = same_block or u_turn if not is_feedback: - mid_x = (p1_out.x() + p2_in.x()) * 0.5 - candidate = [ - p1, p1_out, - QPointF(mid_x, p1.y()), - QPointF(mid_x, p2.y()), - p2_in, p2 - ] + if abs(p1.y() - p2.y()) < self.AXIS_EPS: + straight = [p1, p1_out, p2_in, p2] + path = self._path_from(straight) + if not (path.intersects(src_rect) or path.intersects(dst_rect)): + return straight + + if abs(p1.y() - p2.y()) <= self.JOG_EPS: + candidate = [p1, p1_out, QPointF(p2.x(), p1.y()), p2] + else: + mid_x = (p1_out.x() + p2_in.x()) * 0.5 + candidate = [ + p1, p1_out, + QPointF(mid_x, p1.y()), + QPointF(mid_x, p2.y()), + p2_in, p2 + ] path = self._path_from(candidate) if not (path.intersects(src_rect) or path.intersects(dst_rect)): - return candidate + return self._simplify_orthogonal_route(candidate) # fallback / feedback routing candidates_y = [ @@ -299,23 +401,93 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: key=lambda y: abs(p1.y() - y) + abs(p2.y() - y) ) - return [ - p1, p1_out, - QPointF(p1_out.x(), route_y), - QPointF(p2_in.x(), route_y), - p2_in, p2 - ] + if src_rect.left() <= p2_in.x() <= src_rect.right(): + approach_x = ( + src_rect.left() - self.DETOUR + if dst_in_sign < 0 + else src_rect.right() + self.DETOUR + ) + return self._simplify_orthogonal_route( + [ + p1, + p1_out, + QPointF(p1_out.x(), route_y), + QPointF(approach_x, route_y), + QPointF(approach_x, p2.y()), + p2, + ] + ) + + return self._simplify_orthogonal_route( + [ + p1, p1_out, + QPointF(p1_out.x(), route_y), + QPointF(p2_in.x(), route_y), + p2_in, p2 + ] + ) + + def _wire_side_sign(self, anchor: QPointF, rect: QRectF) -> int: + """Return -1 when the anchor is on the left edge, +1 on the right.""" + dist_left = abs(anchor.x() - rect.left()) + dist_right = abs(anchor.x() - rect.right()) + return -1 if dist_left <= dist_right else 1 + + def _routing_rect_for_port(self, port_item: PortItem) -> QRectF: + """Return the scene rectangle used for obstacle avoidance.""" + view = port_item.parent_block.view + return view.routing_rect_for_port_item(port_item) + + def _wire_length(self, points: list[QPointF]) -> float: + total = 0.0 + for index in range(len(points) - 1): + a = points[index] + b = points[index + 1] + total += abs(a.x() - b.x()) + abs(a.y() - b.y()) + return total + + def _simplify_orthogonal_route(self, points: list[QPointF]) -> list[QPointF]: + if len(points) <= 2: + return [QPointF(point) for point in points] + + simplified = [QPointF(points[0])] + for index in range(1, len(points) - 1): + prev_pt = simplified[-1] + current = points[index] + next_pt = points[index + 1] + same_vertical = ( + abs(prev_pt.x() - current.x()) < self.AXIS_EPS + and abs(current.x() - next_pt.x()) < self.AXIS_EPS + ) + same_horizontal = ( + abs(prev_pt.y() - current.y()) < self.AXIS_EPS + and abs(current.y() - next_pt.y()) < self.AXIS_EPS + ) + if same_vertical or same_horizontal: + continue + simplified.append(QPointF(current)) + simplified.append(QPointF(points[-1])) + return simplified def _snap(self, v: float) -> float: """Snap a scalar coordinate to the routing grid.""" return round(v / self.GRID) * self.GRID - def _apply_route(self, points: list[QPointF]): + def _apply_route(self, points: list[QPointF], *, simplify: bool = True): """Apply a route by building and setting the corresponding path.""" - path = QPainterPath(points[0]) - for p in points[1:]: - path.lineTo(p) + cleaned = ( + self._simplify_orthogonal_route(points) + if simplify + else [QPointF(point) for point in points] + ) + if len(cleaned) < 2: + return + path = QPainterPath(cleaned[0]) + for point in cleaned[1:]: + path.lineTo(point) self.setPath(path) + if self.route is not None: + self.route.points = cleaned def _path_from(self, pts: list[QPointF]) -> QPainterPath: """Build a painter path from an ordered list of route points.""" diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py new file mode 100644 index 0000000..951df88 --- /dev/null +++ b/pySimBlocks/gui/graphics/group_item.py @@ -0,0 +1,490 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QPainterPath +from PySide6.QtGui import QBrush, QFont, QPainter, QPen +from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QGraphicsTextItem, QStyle + +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + +if TYPE_CHECKING: + from pySimBlocks.gui.widgets.diagram_view import DiagramView + + +class GroupBoundaryPortItem(QGraphicsItem): + """Visual port on a group rectangle border.""" + + R = 6 + L = 15 + H = 10 + + MARGIN = 4 + + def __init__(self, boundary: BoundaryPort, parent_group: "GroupItem"): + super().__init__(parent_group) + self.boundary = boundary + self.parent_group = parent_group + self.setAcceptedMouseButtons(Qt.LeftButton) + t = parent_group.view.theme + self.label = QGraphicsTextItem(self) + self.label.setDefaultTextColor(t.text) + self.label.setFont(QFont("Sans Serif", 8)) + self.update_port_label() + + @property + def is_input(self) -> bool: + return self.boundary.direction == "input" + + @property + def is_on_left_side(self) -> bool: + return self.pos().x() < (self.parent_group.rect().width() * 0.5) + + def connection_anchor(self) -> QPointF: + if self.is_input: + x = -self.R if self.is_on_left_side else self.R + local = QPointF(x, 0) + else: + x = self.L if not self.is_on_left_side else -self.L + local = QPointF(x, 0) + return self.mapToScene(local) + + def update_port_label(self) -> None: + """Refresh the external port label shown next to the boundary port.""" + controller = self.parent_group.view.project_controller + text = "" + if controller is not None: + text = controller.boundary_port_flow_label( + self.parent_group.group, + self.boundary, + ) + self.label.setPlainText(text) + self.label.setVisible(bool(text)) + self._layout_port_label() + + def _layout_port_label(self) -> None: + label_rect = self.label.boundingRect() + if self.is_on_left_side: + self.label.setPos( + self.R + self.MARGIN, + -label_rect.height() / 2, + ) + else: + self.label.setPos( + -label_rect.width() - self.R - self.MARGIN, + -label_rect.height() / 2, + ) + + def boundingRect(self) -> QRectF: + return QRectF(-10, -10, 20, 20) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemScenePositionHasChanged: + self._layout_port_label() + return super().itemChange(change, value) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.parent_group.view.create_connection_event(self) + event.accept() + return + super().mousePressEvent(event) + + def shape(self): + path = QPainterPath() + if self.is_input: + path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + else: + tip_x = self.L if not self.is_on_left_side else -self.L + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + path.lineTo(tip_x, 0) + path.closeSubpath() + return path + + def paint(self, painter, option, widget=None): + t = self.parent_group.view.theme + painter.setRenderHint(QPainter.Antialiasing) + fill = t.port_in if self.is_input else t.port_out + painter.setBrush(QBrush(fill)) + painter.setPen(QPen(t.block_border, 1)) + if self.is_input: + painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + else: + from PySide6.QtGui import QPainterPath + + path = QPainterPath() + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + tip_x = self.L if not self.is_on_left_side else -self.L + path.lineTo(tip_x, 0) + path.closeSubpath() + painter.drawPath(path) + + +class GroupItem(QGraphicsRectItem): + """Render a visual group container on the diagram.""" + + MIN_WIDTH = 80.0 + MIN_HEIGHT = 50.0 + GRID_DX = 5 + GRID_DY = 5 + SELECTION_HANDLE_SIZE = 8 + SELECTION_HANDLE_HIT_SIZE = 16 + + def __init__(self, group: VisualGroup, view: "DiagramView"): + layout = group.layout or {} + width = max(float(layout.get("width", 160.0)), self.MIN_WIDTH) + height = max(float(layout.get("height", 100.0)), self.MIN_HEIGHT) + super().__init__(0, 0, width, height) + self.group = group + self.view = view + self.orientation = layout.get("orientation", "normal") + if self.orientation not in {"normal", "flipped"}: + self.orientation = "normal" + self.boundary_port_items: dict[str, GroupBoundaryPortItem] = {} + self._resize_handle: str | None = None + self._resize_start_mouse: QPointF | None = None + self._resize_start_pos: QPointF | None = None + self._resize_start_width = width + self._resize_start_height = height + self._interaction_start_pos: QPointF | None = None + self._interaction_start_rect: QRectF | None = None + self._syncing_geometry = False + + x = float(layout.get("x", 0.0)) + y = float(layout.get("y", 0.0)) + self.setPos(QPointF(x, y)) + self.setFlag(QGraphicsRectItem.ItemIsMovable) + self.setFlag(QGraphicsRectItem.ItemIsSelectable) + self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges) + self.setZValue(-1) + self.sync_boundary_ports() + + def _border_boundary_ports(self) -> list[BoundaryPort]: + """Return boundary ports that should appear on the group rectangle.""" + return list(self.group.boundary_ports) + + def boundary_anchor_for(self, boundary: BoundaryPort) -> QPointF: + """Return the scene anchor for a boundary port on this group.""" + port_item = self.boundary_port_items.get(boundary.uid) + if port_item is not None: + return port_item.connection_anchor() + return self._boundary_anchor_from_geometry(boundary) + + def _boundaries_for_direction(self, direction: str) -> list[BoundaryPort]: + """Return border ports for one side in stable display order.""" + previous_y = { + uid: float(item.pos().y()) + for uid, item in self.boundary_port_items.items() + } + return sorted( + [ + port + for port in self._border_boundary_ports() + if port.direction == direction + ], + key=lambda boundary: self._border_sort_key(boundary, previous_y), + ) + + def _boundary_anchor_from_geometry(self, boundary: BoundaryPort) -> QPointF: + """Compute a boundary anchor when no border port item is shown.""" + rect = self.rect() + same_direction = self._boundaries_for_direction(boundary.direction) + if boundary not in same_direction: + same_direction = [ + port + for port in self.group.boundary_ports + if port.direction == boundary.direction + ] + index = same_direction.index(boundary) if boundary in same_direction else 0 + total = len(same_direction) + y = rect.height() * (index + 1) / (total + 1) + flipped = self.orientation == "flipped" + if boundary.direction == "input": + local = ( + QPointF(rect.width() + GroupBoundaryPortItem.R, y) + if flipped + else QPointF(-GroupBoundaryPortItem.R, y) + ) + else: + local = ( + QPointF(-GroupBoundaryPortItem.L, y) + if flipped + else QPointF(rect.width() + GroupBoundaryPortItem.L, y) + ) + return self.mapToScene(local) + + def _border_sort_key( + self, + boundary: BoundaryPort, + previous_y: dict[str, float], + ) -> tuple[float, str]: + """Keep border ports in a stable vertical order across rebuilds.""" + if boundary.uid in previous_y: + return (previous_y[boundary.uid], boundary.uid) + if boundary.proxy_layout: + return (float(boundary.proxy_layout.get("y", 0.0)), boundary.uid) + return (1e9, boundary.linked_port_uid or boundary.uid) + + def sync_boundary_ports(self) -> None: + """Rebuild boundary port items from group metadata.""" + previous_y = { + uid: float(item.pos().y()) + for uid, item in self.boundary_port_items.items() + } + for item in list(self.boundary_port_items.values()): + item.setParentItem(None) + self.boundary_port_items.clear() + + border_ports = self._border_boundary_ports() + inputs = sorted( + [p for p in border_ports if p.direction == "input"], + key=lambda boundary: self._border_sort_key(boundary, previous_y), + ) + outputs = sorted( + [p for p in border_ports if p.direction == "output"], + key=lambda boundary: self._border_sort_key(boundary, previous_y), + ) + rect = self.rect() + flipped = self.orientation == "flipped" + width = rect.width() + + for index, boundary in enumerate(inputs): + item = GroupBoundaryPortItem(boundary, self) + y = rect.height() * (index + 1) / (len(inputs) + 1) + item.setPos(width if flipped else 0, y) + item.update_port_label() + self.boundary_port_items[boundary.uid] = item + + for index, boundary in enumerate(outputs): + item = GroupBoundaryPortItem(boundary, self) + y = rect.height() * (index + 1) / (len(outputs) + 1) + item.setPos(0 if flipped else width, y) + item.update_port_label() + self.boundary_port_items[boundary.uid] = item + + def set_orientation(self, orientation: str) -> None: + if orientation not in {"normal", "flipped"}: + return + self.orientation = orientation + self.sync_boundary_ports() + layout = dict(self.group.layout or {}) + layout["orientation"] = orientation + self.group.layout = layout + members = set( + self.view.project_controller._group_content_uids_for_group(self.group) + ) + for conn_inst, conn_item in self.view.connections.items(): + if ( + conn_inst.src_block().uid in members + or conn_inst.dst_block().uid in members + ) and conn_item.is_manual: + conn_item.invalidate_manual_route() + self.view.on_group_moved(self) + self.update() + + def refresh_boundary_port_labels(self) -> None: + """Update external port labels after diagram connections change.""" + for item in self.boundary_port_items.values(): + item.update_port_label() + + def get_boundary_anchor(self, boundary_uid: str) -> QPointF | None: + boundary = next( + (port for port in self.group.boundary_ports if port.uid == boundary_uid), + None, + ) + if boundary is None: + return None + return self.boundary_anchor_for(boundary) + + def find_boundary_for_member_port(self, block_uid: str, port_name: str) -> str | None: + key = f"{block_uid}:{port_name}" + for boundary in self.group.boundary_ports: + if boundary.linked_port_uid == key: + return boundary.uid + return None + + def paint(self, painter, option, widget=None): + t = self.view.theme + selected = bool(option.state & QStyle.State_Selected) + painter.setRenderHint(QPainter.Antialiasing) + + if selected: + painter.setBrush(QBrush(t.block_bg_selected)) + painter.setPen(QPen(t.block_border_selected, 3)) + else: + painter.setBrush(QBrush(t.block_bg)) + painter.setPen(QPen(t.block_border, 2, Qt.DashLine)) + + painter.drawRect(self.rect()) + + painter.setPen(QPen(t.text_selected if selected else t.text)) + painter.setFont(QFont("Sans Serif", 9, QFont.Bold)) + painter.drawText( + self.rect().adjusted(4, 4, -4, -4), + int(Qt.AlignTop | Qt.AlignHCenter), + self.group.name, + ) + + if selected: + half = self.SELECTION_HANDLE_SIZE / 2 + r = self.rect() + corners = [ + (r.left(), r.top()), + (r.right(), r.top()), + (r.left(), r.bottom()), + (r.right(), r.bottom()), + ] + painter.setPen(QPen(t.block_border_selected, 1)) + painter.setBrush(t.text_selected) + for x, y in corners: + painter.drawRect( + x - half, y - half, self.SELECTION_HANDLE_SIZE, self.SELECTION_HANDLE_SIZE + ) + + def mousePressEvent(self, event): + self._interaction_start_pos = QPointF(self.pos()) + self._interaction_start_rect = QRectF(self.rect()) + if self.isSelected(): + handle = self._handle_at(event.pos()) + if handle is not None: + self._resize_handle = handle + self._resize_start_mouse = event.scenePos() + self._resize_start_pos = self.pos() + self._resize_start_width = self.rect().width() + self._resize_start_height = self.rect().height() + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._resize_handle and self._resize_start_mouse and self._resize_start_pos: + delta = event.scenePos() - self._resize_start_mouse + dx = round(delta.x() / self.GRID_DX) * self.GRID_DX + dy = round(delta.y() / self.GRID_DY) * self.GRID_DY + + start_x = self._resize_start_pos.x() + start_y = self._resize_start_pos.y() + start_w = self._resize_start_width + start_h = self._resize_start_height + + if self._resize_handle in ("tl", "bl"): + new_x = min(start_x + dx, start_x + start_w - self.MIN_WIDTH) + new_w = max(self.MIN_WIDTH, (start_x + start_w) - new_x) + else: + new_x = start_x + new_w = max(self.MIN_WIDTH, start_w + dx) + + if self._resize_handle in ("tl", "tr"): + new_y = min(start_y + dy, start_y + start_h - self.MIN_HEIGHT) + new_h = max(self.MIN_HEIGHT, (start_y + start_h) - new_y) + else: + new_y = start_y + new_h = max(self.MIN_HEIGHT, start_h + dy) + + self.setPos(QPointF(new_x, new_y)) + self.setRect(0, 0, new_w, new_h) + self.sync_boundary_ports() + self.view.on_group_moved(self) + self.update() + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + start_pos = self._interaction_start_pos + start_rect = self._interaction_start_rect + end_pos = QPointF(self.pos()) + end_rect = QRectF(self.rect()) + + self._resize_handle = None + self._resize_start_mouse = None + self._resize_start_pos = None + self._interaction_start_pos = None + self._interaction_start_rect = None + super().mouseReleaseEvent(event) + + if start_pos is None or start_rect is None: + return + if start_pos != end_pos or start_rect != end_rect: + if self.view.project_controller: + self.view.project_controller.execute_move_resize_group( + self.group.uid, + start_pos, + start_rect, + end_pos, + end_rect, + ) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemPositionChange and self.scene(): + if self._syncing_geometry: + return value + x = round(value.x() / self.GRID_DX) * self.GRID_DX + y = round(value.y() / self.GRID_DY) * self.GRID_DY + return QPointF(x, y) + + if ( + change == QGraphicsItem.ItemPositionHasChanged + and not self._syncing_geometry + and self._resize_handle is None + ): + self.view.on_group_moved(self) + + return super().itemChange(change, value) + + def apply_geometry(self, pos: QPointF, rect: QRectF) -> None: + """Apply position and size without triggering layout side effects.""" + self._syncing_geometry = True + try: + self.setRect(0, 0, rect.width(), rect.height()) + self.setPos(QPointF(pos)) + self.sync_boundary_ports() + finally: + self._syncing_geometry = False + self.group.layout = { + "x": float(pos.x()), + "y": float(pos.y()), + "width": float(rect.width()), + "height": float(rect.height()), + "orientation": self.orientation, + } + + def mouseDoubleClickEvent(self, event): + self.view.enter_group(self.group.uid) + event.accept() + + def _handle_hit_rects(self) -> dict[str, QRectF]: + half = self.SELECTION_HANDLE_HIT_SIZE / 2 + r = self.rect() + return { + "tl": QRectF( + r.left() - half, r.top() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + "tr": QRectF( + r.right() - half, r.top() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + "bl": QRectF( + r.left() - half, r.bottom() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + "br": QRectF( + r.right() - half, r.bottom() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + } + + def _handle_at(self, local_pos: QPointF) -> str | None: + for name, rect in self._handle_hit_rects().items(): + if rect.contains(local_pos): + return name + return None diff --git a/pySimBlocks/gui/graphics/group_proxy_item.py b/pySimBlocks/gui/graphics/group_proxy_item.py new file mode 100644 index 0000000..5cf282b --- /dev/null +++ b/pySimBlocks/gui/graphics/group_proxy_item.py @@ -0,0 +1,245 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QBrush, QFont, QPainter, QPen, QPainterPath +from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle + +from pySimBlocks.gui.models.visual_group import BoundaryPort + +if TYPE_CHECKING: + from pySimBlocks.gui.widgets.diagram_view import DiagramView + + +class GroupProxyPortItem(QGraphicsItem): + """Single visible port on a GroupIn or GroupOut proxy block.""" + + R = 6 + L = 15 + H = 10 + + def __init__(self, is_output: bool, parent_proxy: "GroupProxyItem"): + super().__init__(parent_proxy) + self.is_output = is_output + self.parent_proxy = parent_proxy + + @property + def is_on_left_side(self) -> bool: + return self.pos().x() < (self.parent_proxy.rect().width() * 0.5) + + def connection_anchor(self) -> QPointF: + if self.is_output: + x = self.L if not self.is_on_left_side else -self.L + local = QPointF(x, 0) + else: + x = -self.R if self.is_on_left_side else self.R + local = QPointF(x, 0) + return self.mapToScene(local) + + def boundingRect(self) -> QRectF: + return QRectF(-12, -12, 24, 24) + + def shape(self): + path = QPainterPath() + if self.is_output: + tip_x = self.L if not self.is_on_left_side else -self.L + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + path.lineTo(tip_x, 0) + path.closeSubpath() + else: + path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + return path + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.parent_proxy.view.create_connection_event(self) + event.accept() + return + super().mousePressEvent(event) + + def paint(self, painter, option, widget=None): + t = self.parent_proxy.view.theme + painter.setRenderHint(QPainter.Antialiasing) + fill = t.port_out if self.is_output else t.port_in + painter.setBrush(QBrush(fill)) + painter.setPen(QPen(t.block_border, 1)) + if self.is_output: + path = QPainterPath() + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + tip_x = self.L if not self.is_on_left_side else -self.L + path.lineTo(tip_x, 0) + path.closeSubpath() + painter.drawPath(path) + else: + painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + + +class GroupProxyItem(QGraphicsRectItem): + """Render a GroupIn or GroupOut proxy inside a visual group. + + GroupIn exposes only an output toward group members; its external input + lives on the parent GroupItem border. GroupOut exposes only an input from + members; its external output lives on the parent GroupItem border. + """ + + WIDTH = 56.0 + HEIGHT = 45.0 + TYPE_LABEL_MIN_HEIGHT = 40.0 + GRID_DX = 5 + GRID_DY = 5 + + def __init__(self, boundary: BoundaryPort, view: "DiagramView"): + super().__init__(0, 0, self.WIDTH, self.HEIGHT) + self.boundary = boundary + self.view = view + layout = boundary.proxy_layout or {} + self.orientation = layout.get("orientation", "normal") + if self.orientation not in {"normal", "flipped"}: + self.orientation = "normal" + + if self.is_group_in: + self.port_item = GroupProxyPortItem(is_output=True, parent_proxy=self) + else: + self.port_item = GroupProxyPortItem(is_output=False, parent_proxy=self) + self._layout_port() + + self.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0)))) + self.setFlag(QGraphicsRectItem.ItemIsMovable) + self.setFlag(QGraphicsRectItem.ItemIsSelectable) + self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges) + self.setZValue(0) + self._interaction_start_pos: QPointF | None = None + + @property + def is_group_in(self) -> bool: + return self.boundary.direction == "input" + + @property + def is_group_out(self) -> bool: + return self.boundary.direction == "output" + + @property + def kind_label(self) -> str: + return "In" if self.is_group_in else "Out" + + def center_label(self) -> str: + """Return proxy label from member port or manual override.""" + controller = self.view.project_controller + if controller is None: + return self.kind_label + text = controller.boundary_proxy_label(self.boundary) + return text if text else self.kind_label + + def member_anchor(self) -> QPointF: + """Anchor used for wires between the proxy and group members.""" + return self.port_item.connection_anchor() + + def _layout_port(self) -> None: + rect = self.rect() + mid_y = rect.height() / 2 + flipped = self.orientation == "flipped" + if self.is_group_in: + self.port_item.setPos(0 if flipped else rect.width(), mid_y) + else: + self.port_item.setPos(rect.width() if flipped else 0, mid_y) + + def set_orientation(self, orientation: str) -> None: + if orientation not in {"normal", "flipped"}: + return + self.orientation = orientation + self._layout_port() + layout = dict(self.boundary.proxy_layout or {}) + layout["orientation"] = orientation + self.boundary.proxy_layout = layout + for conn_item in self.view.connections.values(): + if conn_item.is_manual: + conn_item.invalidate_manual_route() + self.view.on_proxy_moved(self) + self.update() + + def paint(self, painter, option, widget=None): + t = self.view.theme + selected = bool(option.state & QStyle.State_Selected) + rect = self.rect() + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(QBrush(t.block_bg_selected if selected else t.block_bg)) + painter.setPen(QPen(t.block_border_selected if selected else t.block_border, 2)) + painter.drawRect(rect) + + name_font = QFont("Sans Serif", 9) + painter.setFont(name_font) + painter.setPen(QPen(t.text_selected if selected else t.text)) + name_rect = QRectF(rect.x(), rect.y(), rect.width(), rect.height() * 0.60) + painter.drawText(name_rect, int(Qt.AlignCenter | Qt.AlignBottom), self.center_label()) + + if rect.height() >= self.TYPE_LABEL_MIN_HEIGHT: + type_font = QFont("Sans Serif", 8) + type_font.setItalic(True) + painter.setFont(type_font) + painter.setPen(QPen(t.text_type_selected if selected else t.text_type)) + type_rect = QRectF( + rect.x(), + rect.y() + rect.height() * 0.58, + rect.width(), + rect.height() * 0.38, + ) + painter.drawText(type_rect, int(Qt.AlignCenter | Qt.AlignTop), self.kind_label) + + def mousePressEvent(self, event): + self._interaction_start_pos = QPointF(self.pos()) + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + start_pos = self._interaction_start_pos + end_pos = QPointF(self.pos()) + self._interaction_start_pos = None + super().mouseReleaseEvent(event) + if ( + start_pos is not None + and start_pos != end_pos + and self.view.project_controller is not None + and self.view.current_view_group_uid is not None + ): + self.view.project_controller.execute_move_proxy_layout( + self.view.current_view_group_uid, + self.boundary.uid, + start_pos, + end_pos, + ) + + def contextMenuEvent(self, event): + if self.view is not None: + global_pos = ( + event.screenPos() + if hasattr(event, "screenPos") + else event.globalPos() + ) + self.view._show_boundary_port_context_menu(self, global_pos) + event.accept() + return + super().contextMenuEvent(event) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemPositionChange and self.scene(): + x = round(value.x() / self.GRID_DX) * self.GRID_DX + y = round(value.y() / self.GRID_DY) * self.GRID_DY + return QPointF(x, y) + + if change == QGraphicsItem.ItemPositionHasChanged and self.view.project_controller: + pos = self.pos() + layout = dict(self.boundary.proxy_layout or {}) + layout["x"] = float(pos.x()) + layout["y"] = float(pos.y()) + layout["orientation"] = self.orientation + self.boundary.proxy_layout = layout + self.view.on_proxy_moved(self) + + return super().itemChange(change, value) diff --git a/pySimBlocks/gui/graphics/manual_boundary_wire_item.py b/pySimBlocks/gui/graphics/manual_boundary_wire_item.py new file mode 100644 index 0000000..d06a9d7 --- /dev/null +++ b/pySimBlocks/gui/graphics/manual_boundary_wire_item.py @@ -0,0 +1,53 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import QPainterPath, QPainterPathStroker, QPen +from PySide6.QtWidgets import QGraphicsPathItem + + +class ManualBoundaryWireItem(QGraphicsPathItem): + """Visual-only wire for an incomplete manual group boundary.""" + + def __init__( + self, + view, + src_anchor, + dst_anchor, + *, + group_uid: str, + boundary_uid: str, + side: str, + ): + super().__init__() + self._view = view + self._src_anchor = src_anchor + self._dst_anchor = dst_anchor + self.group_uid = group_uid + self.boundary_uid = boundary_uid + self.side = side + self.setPen(QPen(view.theme.wire, 2, Qt.DashLine)) + self.setZValue(1) + self.setFlag(QGraphicsPathItem.ItemIsSelectable, True) + self.setAcceptedMouseButtons(Qt.LeftButton) + self.update_position() + + def update_position(self) -> None: + p1 = self._src_anchor() + p2 = self._dst_anchor() + mid = QPointF((p1.x() + p2.x()) * 0.5, p1.y()) + path = QPainterPath() + path.moveTo(p1) + path.lineTo(mid) + path.lineTo(QPointF(mid.x(), p2.y())) + path.lineTo(p2) + self.setPath(path) + + def shape(self): + stroker = QPainterPathStroker() + stroker.setWidth(12) + return stroker.createStroke(self.path()) diff --git a/pySimBlocks/gui/group_boundary_labels.py b/pySimBlocks/gui/group_boundary_labels.py new file mode 100644 index 0000000..aeee02e --- /dev/null +++ b/pySimBlocks/gui/group_boundary_labels.py @@ -0,0 +1,56 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from pySimBlocks.gui.services.group_boundary_service import ( + find_connection_for_boundary, + find_port, +) +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + + +def _port_display(port) -> str: + return str(port.display_as or port.name) + + +def proxy_default_label(boundary: BoundaryPort) -> str: + """Return the default GroupIn/GroupOut name for one boundary direction.""" + return "In" if boundary.direction == "input" else "Out" + + +def manual_boundary_display_label(boundary: BoundaryPort) -> str: + """Return the proxy name shown on the group border and inside the group.""" + if boundary.label.strip(): + return boundary.label.strip() + return proxy_default_label(boundary) + + +def boundary_port_label( + state: ProjectState, + group: VisualGroup, + boundary: BoundaryPort, +) -> str: + """Return the label shown on a group border port.""" + if boundary.origin == "manual": + return manual_boundary_display_label(boundary) + if boundary.label.strip(): + return boundary.label.strip() + + internal = ( + find_port(state, boundary.linked_port_uid) + if boundary.linked_port_uid + else None + ) + if internal is None: + return "" + + connection = find_connection_for_boundary(state, boundary) + if connection is None: + return _port_display(internal) + + if boundary.direction == "input": + return _port_display(connection.src_port) + return _port_display(connection.dst_port) diff --git a/pySimBlocks/gui/group_ports.py b/pySimBlocks/gui/group_ports.py new file mode 100644 index 0000000..032b7b3 --- /dev/null +++ b/pySimBlocks/gui/group_ports.py @@ -0,0 +1,10 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +"""Constants for visual-only group port palette entries.""" + +GROUP_PORTS_CATEGORY = "group_ports" +GROUP_IN_TYPE = "In" +GROUP_OUT_TYPE = "Out" diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py index 9188a4e..aa54bf5 100644 --- a/pySimBlocks/gui/main_window.py +++ b/pySimBlocks/gui/main_window.py @@ -25,7 +25,7 @@ from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction, QKeySequence -from PySide6.QtWidgets import QMainWindow, QSplitter +from PySide6.QtWidgets import QMainWindow, QSplitter, QVBoxLayout, QWidget from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.dialogs.unsaved_dialog import UnsavedChangesDialog @@ -37,8 +37,14 @@ from pySimBlocks.gui.services.yaml_tools import cleanup_runtime_project_yaml from pySimBlocks.gui.undo_redo.undo_redo_manager import UndoManager from pySimBlocks.gui.widgets.block_list import BlockList +from pySimBlocks.gui.group_ports import ( + GROUP_IN_TYPE, + GROUP_OUT_TYPE, + GROUP_PORTS_CATEGORY, +) from pySimBlocks.gui.widgets.diagram_view import DiagramView from pySimBlocks.gui.widgets.toolbar_view import ToolBarView +from pySimBlocks.gui.widgets.view_navigation_bar import ViewNavigationBar from pySimBlocks.tools.blocks_registry import load_block_registry @@ -83,12 +89,24 @@ def __init__(self, project_path: Path): ) self.view.project_controller = self.project_controller self.blocks = BlockList(self.get_categories, self.get_blocks, self.resolve_block_meta) + self.view.group_view_changed.connect(self.blocks.rebuild) + self.nav_bar = ViewNavigationBar(self._resolve_group_name_for_nav) + self.view.view_stack_changed.connect(self._update_view_navigation_bar) + self.nav_bar.navigate_requested.connect(self.view.navigate_to_depth) self.toolbar = ToolBarView(self.saver, self.runner, self.project_controller) + diagram_panel = QWidget() + diagram_layout = QVBoxLayout(diagram_panel) + diagram_layout.setContentsMargins(0, 0, 0, 0) + diagram_layout.setSpacing(0) + diagram_layout.addWidget(self.nav_bar) + diagram_layout.addWidget(self.view, 1) + splitter = QSplitter(Qt.Horizontal) splitter.addWidget(self.blocks) - splitter.addWidget(self.view) + splitter.addWidget(diagram_panel) splitter.setSizes([180, 800]) + self._update_view_navigation_bar() self.setCentralWidget(splitter) self.addToolBar(self.toolbar) @@ -129,35 +147,30 @@ def __init__(self, project_path: Path): # Registry # -------------------------------------------------------------------------- - def get_categories(self) -> List[str]: - """Return the sorted list of block categories from the registry. - - Returns: - Sorted list of category name strings. - """ - return sorted(self.block_registry.keys()) + def _resolve_group_name_for_nav(self, group_uid: str) -> str | None: + group = self.project_state.get_visual_group(group_uid) + return group.name if group is not None else None - def get_blocks(self, category: str) -> List[str]: - """Return the sorted list of block type names within a category. + def _update_view_navigation_bar(self) -> None: + self.nav_bar.set_view_stack(self.view.view_stack) - Args: - category: Category name to look up. + def get_categories(self) -> List[str]: + """Return the sorted list of block categories from the registry.""" + categories = list(self.block_registry.keys()) + if self.view.current_view_group_uid is not None: + categories.append(GROUP_PORTS_CATEGORY) + return sorted(categories) - Returns: - Sorted list of block type name strings. - """ + def get_blocks(self, category: str) -> List[str]: + """Return the sorted list of block type names within a category.""" + if category == GROUP_PORTS_CATEGORY: + return [GROUP_IN_TYPE, GROUP_OUT_TYPE] return sorted(self.block_registry.get(category, {}).keys()) def resolve_block_meta(self, category: str, block_type: str) -> BlockMeta: - """Return the :class:`BlockMeta` for a given category and block type. - - Args: - category: Category name of the block. - block_type: Type name of the block within the category. - - Returns: - The :class:`BlockMeta` descriptor for the requested block. - """ + """Return the :class:`BlockMeta` for a given category and block type.""" + if category == GROUP_PORTS_CATEGORY: + raise KeyError("Group port palette entries are visual-only.") return self.block_registry[category][block_type] diff --git a/pySimBlocks/gui/models/__init__.py b/pySimBlocks/gui/models/__init__.py index f89cebb..41442b8 100644 --- a/pySimBlocks/gui/models/__init__.py +++ b/pySimBlocks/gui/models/__init__.py @@ -22,10 +22,13 @@ from pySimBlocks.gui.models.connection_instance import ConnectionInstance from pySimBlocks.gui.models.port_instance import PortInstance from pySimBlocks.gui.models.project_state import ProjectState +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup __all__ = [ "BlockInstance", "ConnectionInstance", "PortInstance", - "ProjectState" + "ProjectState", + "BoundaryPort", + "VisualGroup", ] diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py index b4e397b..1c4c598 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -23,6 +23,7 @@ from pySimBlocks.gui.models.block_instance import BlockInstance, PortInstance from pySimBlocks.gui.models.connection_instance import ConnectionInstance from pySimBlocks.gui.models.project_simulation_params import ProjectSimulationParams +from pySimBlocks.gui.models.visual_group import VisualGroup class ProjectState: """Store the editable state of a GUI project. @@ -55,6 +56,7 @@ def __init__(self, directory_path: Path): self.logging: list[str] = [] self.logs: dict = {} self.plots: list[dict[str, str | list[str]]] = [] + self.visual_groups: list[VisualGroup] = [] # --- Public methods --- @@ -67,6 +69,7 @@ def clear(self): self.logs.clear() self.logging.clear() self.plots.clear() + self.visual_groups.clear() self.simulation.clear() @@ -113,6 +116,7 @@ def remove_block(self, block_instance: BlockInstance): """ if block_instance in self.blocks: self.blocks.remove(block_instance) + self.remove_block_from_groups(block_instance.uid) def add_connection(self, conn: ConnectionInstance): """Add a connection instance to the project. @@ -196,3 +200,29 @@ def can_plot(self) -> tuple[bool, str]: ) return True, "Plotting is available." + + def get_visual_group(self, group_uid: str) -> VisualGroup | None: + """Return a visual group by UID, if present.""" + for group in self.visual_groups: + if group.uid == group_uid: + return group + return None + + def remove_visual_group(self, group_uid: str) -> bool: + """Remove a visual group by UID.""" + group = self.get_visual_group(group_uid) + if group is None: + return False + self.visual_groups.remove(group) + return True + + def remove_block_from_groups(self, block_uid: str) -> None: + """Remove a block UID from all groups and drop empty groups.""" + to_remove: list[VisualGroup] = [] + for group in self.visual_groups: + if block_uid in group.members: + group.members = [uid for uid in group.members if uid != block_uid] + if not group.members and not group.child_group_uids: + to_remove.append(group) + for group in to_remove: + self.visual_groups.remove(group) diff --git a/pySimBlocks/gui/models/visual_group.py b/pySimBlocks/gui/models/visual_group.py new file mode 100644 index 0000000..f750865 --- /dev/null +++ b/pySimBlocks/gui/models/visual_group.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class BoundaryPort: + """Describe one visual group boundary port.""" + + uid: str + direction: str + linked_port_uid: str = "" + external_port_uid: str = "" + origin: str = "auto" + linked_connection_uid: str = "" + label: str = "" + proxy_uid: str = "" + proxy_layout: dict[str, float] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Serialize the boundary port to a YAML-friendly mapping.""" + out: dict[str, Any] = { + "uid": self.uid, + "direction": self.direction, + "linked_port_uid": self.linked_port_uid, + "external_port_uid": self.external_port_uid, + "origin": self.origin, + "linked_connection_uid": self.linked_connection_uid, + } + if self.label: + out["label"] = self.label + if self.proxy_uid: + out["proxy_uid"] = self.proxy_uid + if self.proxy_layout: + out["proxy_layout"] = self.proxy_layout + return out + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BoundaryPort": + """Build a boundary port from a raw mapping.""" + return cls( + uid=str(data.get("uid", "")), + direction=str(data.get("direction", "")), + linked_port_uid=str(data.get("linked_port_uid", "")), + external_port_uid=str(data.get("external_port_uid", "")), + origin=str(data.get("origin", "auto")), + linked_connection_uid=str(data.get("linked_connection_uid", "")), + label=str(data.get("label", "")), + proxy_uid=str(data.get("proxy_uid", "")), + proxy_layout=dict(data.get("proxy_layout", {})) + if isinstance(data.get("proxy_layout", {}), dict) + else {}, + ) + + +@dataclass +class VisualGroup: + """Store one visual-only group definition.""" + + uid: str + name: str + members: list[str] = field(default_factory=list) + parent_uid: str | None = None + layout: dict[str, float] = field(default_factory=dict) + boundary_ports: list[BoundaryPort] = field(default_factory=list) + child_group_uids: list[str] = field(default_factory=list) + member_layouts: dict[str, dict[str, Any]] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Serialize the visual group to a YAML-friendly mapping.""" + out: dict[str, Any] = { + "uid": self.uid, + "name": self.name, + "parent_uid": self.parent_uid, + "members": list(self.members), + "layout": dict(self.layout), + "boundary_ports": [port.to_dict() for port in self.boundary_ports], + "child_group_uids": list(self.child_group_uids), + "member_layouts": { + uid: dict(layout) for uid, layout in self.member_layouts.items() + }, + } + return out + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "VisualGroup": + """Build a visual group from a raw mapping.""" + boundary_ports_raw = data.get("boundary_ports", []) + boundary_ports = [] + if isinstance(boundary_ports_raw, list): + boundary_ports = [ + BoundaryPort.from_dict(item) + for item in boundary_ports_raw + if isinstance(item, dict) + ] + + members = data.get("members", []) + if not isinstance(members, list): + members = [] + + layout = data.get("layout", {}) + if not isinstance(layout, dict): + layout = {} + + children = data.get("child_group_uids", []) + if not isinstance(children, list): + children = [] + + parent_uid = data.get("parent_uid", None) + if parent_uid is not None: + parent_uid = str(parent_uid) + + member_layouts_raw = data.get("member_layouts", {}) + member_layouts: dict[str, dict[str, Any]] = {} + if isinstance(member_layouts_raw, dict): + for uid, member_layout in member_layouts_raw.items(): + if isinstance(member_layout, dict): + member_layouts[str(uid)] = dict(member_layout) + + return cls( + uid=str(data.get("uid", "")), + name=str(data.get("name", "")), + members=[str(m) for m in members], + parent_uid=parent_uid, + layout=dict(layout), + boundary_ports=boundary_ports, + child_group_uids=[str(c) for c in children], + member_layouts=member_layouts, + ) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 44a1f1f..220a0e0 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -20,6 +20,8 @@ from __future__ import annotations +import copy +import uuid from pathlib import Path from typing import TYPE_CHECKING, Any, Callable @@ -27,9 +29,35 @@ from pySimBlocks.gui.models import ( BlockInstance, + BoundaryPort, ConnectionInstance, PortInstance, ProjectState, + VisualGroup, +) +from pySimBlocks.gui.diagram_clipboard import ( + DiagramClipboard, + capture_selection_clipboard, + clipboard_has_content, + paste_clipboard, + undo_paste, +) +from pySimBlocks.gui.group_boundary_labels import ( + boundary_port_label, + manual_boundary_display_label, +) +from pySimBlocks.gui.services.group_boundary_service import ( + BoundaryWiringState, + apply_wiring_state, + can_complete, + capture_wiring_state, + connection_endpoints, + find_connection_for_boundary, + find_port, + is_complete, + port_key, + validate_external_link, + validate_internal_link, ) from pySimBlocks.gui.widgets.diagram_view import DiagramView from pySimBlocks.gui.blocks.block_meta import BlockMeta @@ -38,11 +66,27 @@ from pySimBlocks.gui.undo_redo.commands import ( AddBlockCommand, AddConnectionCommand, + AddManualBoundaryCommand, + AddToGroupCommand, EditBlockParamsCommand, + EditConnectionRouteCommand, + GroupBlocksCommand, + MoveProxyLayoutCommand, MoveResizeBlockCommand, + MoveResizeGroupCommand, RemoveBlockCommand, RemoveConnectionCommand, + RemoveFromGroupCommand, + RemoveBoundaryPortCommand, + RenameBoundaryPortCommand, + RenameGroupCommand, + PasteClipboardCommand, ToggleOrientationCommand, + ToggleGroupOrientationCommand, + ToggleProxyOrientationCommand, + UngroupCommand, + DeleteGroupCommand, + WireManualBoundaryCommand, ConnectionSnapshot, ) @@ -126,9 +170,32 @@ def add_copy_block(self, block_instance: BlockInstance) -> BlockInstance: Returns: The newly created copy as a :class:`BlockInstance`. """ - copy = BlockInstance.copy(block_instance) - self.undo_manager.push(AddBlockCommand(self, copy)) - return copy + copy_block = BlockInstance.copy(block_instance) + self.undo_manager.push(AddBlockCommand(self, copy_block)) + return copy_block + + def copy_selection(self) -> bool: + """Copy the current diagram selection to the view clipboard.""" + clipboard = capture_selection_clipboard(self) + if clipboard is None: + return False + self.view.clipboard = clipboard + self.view.paste_generation = 0 + return True + + def paste_clipboard_at(self, origin: QPointF) -> bool: + """Paste the view clipboard at ``origin`` (undoable).""" + clipboard = self.view.clipboard + if not clipboard_has_content(clipboard): + return False + if clipboard.boundary_ports and self.view.current_view_group_uid is None: + return False + parent_uid = self.view.current_view_group_uid + self.undo_manager.push( + PasteClipboardCommand(self, clipboard, QPointF(origin), parent_uid) + ) + self.view.paste_generation += 1 + return True def rename_block(self, block_instance: BlockInstance, new_name: str) -> None: """Rename a block and update all references in logging and plot signals. @@ -181,6 +248,589 @@ def remove_block(self, block_instance: BlockInstance) -> None: """ self.undo_manager.push(RemoveBlockCommand(self, block_instance)) + def group_blocks( + self, + blocks: list[BlockInstance], + name: str | None = None, + *, + child_group_uids: list[str] | None = None, + parent_uid: str | None = None, + ) -> VisualGroup | None: + """Create a visual group from blocks and/or child groups (undoable).""" + unique_blocks: list[BlockInstance] = [] + seen = set() + for block in blocks: + if block.uid in seen: + continue + seen.add(block.uid) + unique_blocks.append(block) + + child_uids = list(dict.fromkeys(child_group_uids or [])) + if parent_uid is None: + parent_uid = self.view.current_view_group_uid + + if len(unique_blocks) + len(child_uids) < 2: + return None + + for block in unique_blocks: + if not self._block_at_view_level(block.uid, parent_uid): + return None + + for child_uid in child_uids: + child = self.project_state.get_visual_group(child_uid) + if child is None or child.parent_uid != parent_uid: + return None + + self.undo_manager.push( + GroupBlocksCommand( + self, + unique_blocks, + name, + child_group_uids=child_uids, + parent_uid=parent_uid, + ) + ) + for group in reversed(self.project_state.visual_groups): + if set(group.members) == {b.uid for b in unique_blocks} and set( + group.child_group_uids + ) == set(child_uids): + return group + return None + + def ungroup(self, group_uid: str) -> bool: + """Remove a visual group by UID (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return False + self.undo_manager.push(UngroupCommand(self, group_uid)) + return True + + def delete_group(self, group_uid: str) -> bool: + """Delete a visual group and all nested content (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return False + self.undo_manager.push(DeleteGroupCommand(self, group_uid)) + return True + + def group_selected_blocks(self) -> VisualGroup | None: + """Group the current diagram selection of blocks and/or groups.""" + blocks = self.view.get_selected_block_instances() + child_uids = self.view.get_selected_group_uids() + if len(blocks) + len(child_uids) < 2: + return None + return self.group_blocks( + blocks, + child_group_uids=child_uids, + parent_uid=self.view.current_view_group_uid, + ) + + def ungroup_selected_group(self) -> bool: + """Ungroup the currently selected visual group.""" + group_uid = self.view.get_selected_group_uid() + if group_uid is None: + return False + return self.ungroup(group_uid) + + def add_block_to_group( + self, + group_uid: str, + block_instance: BlockInstance, + layout: dict[str, Any] | None = None, + ) -> bool: + """Add an existing block to a visual group (undoable).""" + if not self._can_add_block_to_group(group_uid, block_instance.uid): + return False + if layout is None: + layout = self._capture_block_layout(block_instance) + self.undo_manager.push( + AddToGroupCommand(self, group_uid, block_instance.uid, layout) + ) + return True + + def add_block_in_group_view( + self, + category: str, + block_type: str, + group_uid: str, + ) -> BlockInstance | None: + """Drop a new palette block into a group's internal view (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return None + self.begin_macro("Add to Group") + try: + block = self.add_block(category, block_type) + layout = self._capture_block_layout(block) + if not self.add_block_to_group(group_uid, block, layout): + return None + return block + finally: + self.end_macro() + + def remove_block_from_group(self, group_uid: str, block_uid: str) -> bool: + """Remove a block from a visual group without deleting it (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None or block_uid not in group.members: + return False + self.undo_manager.push(RemoveFromGroupCommand(self, group_uid, block_uid)) + return True + + def try_wire_boundary_endpoints(self, src, dst) -> bool: + """Wire group boundaries, proxies, or group-to-group borders.""" + from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem + from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyPortItem + from pySimBlocks.gui.graphics.port_item import PortItem + + boundary_ports = [ + endpoint + for endpoint in (src, dst) + if isinstance(endpoint, GroupBoundaryPortItem) + ] + if len(boundary_ports) == 2: + return self._wire_group_boundaries_together( + boundary_ports[0], + boundary_ports[1], + ) + + proxy_port = None + boundary_port = None + block_port_item = None + for endpoint in (src, dst): + if isinstance(endpoint, GroupProxyPortItem): + proxy_port = endpoint + elif isinstance(endpoint, GroupBoundaryPortItem): + boundary_port = endpoint + elif isinstance(endpoint, PortItem): + block_port_item = endpoint + + if proxy_port is not None and boundary_port is not None: + return self._wire_proxy_to_child_group_border(proxy_port, boundary_port) + + if block_port_item is None: + return False + + member_port = block_port_item.instance + + if proxy_port is not None: + group_uid = self.view.current_view_group_uid + if group_uid is None: + return False + boundary_uid = proxy_port.parent_proxy.boundary.uid + return self._wire_manual_boundary_internal( + group_uid, + boundary_uid, + member_port, + ) + + if boundary_port is not None: + if self._wire_child_border_to_parent_member(boundary_port, member_port): + return True + group_uid = boundary_port.parent_group.group.uid + boundary_uid = boundary_port.boundary.uid + return self._wire_manual_boundary_external( + group_uid, + boundary_uid, + member_port, + ) + return False + + def _wire_child_border_to_parent_member( + self, + boundary_port, + member_port: PortInstance, + ) -> bool: + """Wire a child-group border port to a direct member of the active parent.""" + active_uid = self.view.current_view_group_uid + if active_uid is None: + return False + child_group = boundary_port.parent_group.group + parent = self.project_state.get_visual_group(active_uid) + if parent is None or child_group.parent_uid != parent.uid: + return False + if member_port.block.uid not in parent.members: + return False + boundary = boundary_port.boundary + internal = find_port(self.project_state, boundary.linked_port_uid) + if internal is None: + return False + if boundary.direction == "input": + src_port, dst_port = member_port, internal + else: + src_port, dst_port = internal, member_port + if not src_port.is_compatible(dst_port): + return False + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return False + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + + def _wire_group_boundaries_together( + self, + port_a, + port_b, + ) -> bool: + """Connect two group border ports across different groups.""" + group_a = port_a.parent_group.group + group_b = port_b.parent_group.group + if group_a.uid == group_b.uid: + return False + + boundary_a = port_a.boundary + boundary_b = port_b.boundary + member_a = find_port(self.project_state, boundary_a.linked_port_uid) + member_b = find_port(self.project_state, boundary_b.linked_port_uid) + if member_a is None or member_b is None: + return False + + if boundary_a.direction == "output" and boundary_b.direction == "input": + src_port, dst_port = member_a, member_b + elif boundary_a.direction == "input" and boundary_b.direction == "output": + src_port, dst_port = member_b, member_a + else: + return False + + if not src_port.is_compatible(dst_port): + return False + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return False + + from pySimBlocks.gui.undo_redo.commands import AddConnectionCommand + + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + + def _wire_manual_boundary_internal( + self, + group_uid: str, + boundary_uid: str, + member_port: PortInstance, + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or not self._validate_internal_link( + group, boundary, member_port + ): + return False + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + after_wiring = capture_wiring_state(boundary) + after_wiring.linked_port_uid = port_key(member_port) + connection_snapshot = self._connection_snapshot_for_wiring( + group, boundary, after_wiring + ) + after = (after_wiring, connection_snapshot) + self.undo_manager.push( + WireManualBoundaryCommand(self, group_uid, boundary_uid, before, after) + ) + return True + + def _validate_internal_link( + self, + group: VisualGroup, + boundary: BoundaryPort, + member_port: PortInstance, + ) -> bool: + """Validate an internal boundary link against the full group content.""" + content_uids = set(self._group_content_uids_for_group(group)) + return validate_internal_link( + group, + boundary, + member_port, + content_uids=content_uids, + ) + + def _wire_proxy_to_child_group_border( + self, + proxy_port, + child_boundary_port, + ) -> bool: + """Wire a parent-group proxy to a child group's border port.""" + active_uid = self.view.current_view_group_uid + if active_uid is None: + return False + + parent_group = self.project_state.get_visual_group(active_uid) + child_group = child_boundary_port.parent_group.group + if parent_group is None or child_group.uid not in parent_group.child_group_uids: + return False + + boundary_uid = proxy_port.parent_proxy.boundary.uid + boundary = self._find_boundary_port(parent_group, boundary_uid) + if boundary is None: + return False + + member_port = find_port( + self.project_state, + child_boundary_port.boundary.linked_port_uid, + ) + if member_port is None: + return False + + return self._wire_manual_boundary_internal( + active_uid, + boundary_uid, + member_port, + ) + + def _wire_auto_boundary_internal( + self, + group_uid: str, + boundary_uid: str, + member_port: PortInstance, + ) -> bool: + """Restore an auto boundary by wiring its proxy to the member port.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or boundary.origin != "auto" or boundary.linked_connection_uid: + return False + if not self._validate_internal_link(group, boundary, member_port): + return False + if not boundary.external_port_uid: + return False + external = find_port(self.project_state, boundary.external_port_uid) + if external is None: + return False + if boundary.direction == "input": + src_port, dst_port = external, member_port + else: + src_port, dst_port = member_port, external + if not src_port.is_compatible(dst_port): + return False + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return False + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + + def _validate_auto_external_link( + self, + group: VisualGroup, + boundary: BoundaryPort, + external_port: PortInstance, + ) -> PortInstance | None: + """Return the internal port when an incomplete auto boundary may be rewired.""" + if boundary.origin != "auto" or boundary.linked_connection_uid: + return None + if not boundary.linked_port_uid: + return None + internal = find_port(self.project_state, boundary.linked_port_uid) + if internal is None: + return None + members = set(self._group_content_uids_for_group(group)) + if external_port.block.uid in members: + return None + if boundary.direction == "input": + if external_port.direction != "output" or internal.direction != "input": + return None + src_port, dst_port = external_port, internal + else: + if external_port.direction != "input" or internal.direction != "output": + return None + src_port, dst_port = internal, external_port + if not src_port.is_compatible(dst_port): + return None + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return None + return internal + + def _wire_auto_boundary_external( + self, + group_uid: str, + boundary_uid: str, + external_port: PortInstance, + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return False + internal = self._validate_auto_external_link(group, boundary, external_port) + if internal is None: + return False + if boundary.direction == "input": + src_port, dst_port = external_port, internal + else: + src_port, dst_port = internal, external_port + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + + def _wire_manual_boundary_external( + self, + group_uid: str, + boundary_uid: str, + external_port: PortInstance, + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or not validate_external_link(group, boundary, external_port): + return False + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + after_wiring = capture_wiring_state(boundary) + after_wiring.external_port_uid = port_key(external_port) + connection_snapshot = self._connection_snapshot_for_wiring( + group, boundary, after_wiring + ) + after = (after_wiring, connection_snapshot) + self.undo_manager.push( + WireManualBoundaryCommand(self, group_uid, boundary_uid, before, after) + ) + return True + + def _find_boundary_port( + self, + group: VisualGroup, + boundary_uid: str, + ) -> BoundaryPort | None: + return next( + (port for port in group.boundary_ports if port.uid == boundary_uid), + None, + ) + + def _capture_boundary_wire_snapshot( + self, + group_uid: str, + boundary_uid: str, + ) -> tuple[BoundaryWiringState, ConnectionSnapshot | None]: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return BoundaryWiringState(), None + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return BoundaryWiringState(), None + connection = find_connection_for_boundary(self.project_state, boundary) + snapshot = ( + self._capture_connection_snapshot(connection) + if connection is not None + else None + ) + return capture_wiring_state(boundary), snapshot + + def _connection_snapshot_for_wiring( + self, + group: VisualGroup, + boundary: BoundaryPort, + wiring: BoundaryWiringState, + ) -> ConnectionSnapshot | None: + trial = BoundaryPort( + uid=boundary.uid, + direction=boundary.direction, + linked_port_uid=wiring.linked_port_uid, + external_port_uid=wiring.external_port_uid, + origin="manual", + ) + if not can_complete(self.project_state, trial): + return None + endpoints = connection_endpoints(self.project_state, trial) + if endpoints is None: + return None + src_port, dst_port = endpoints + return ConnectionSnapshot( + src_block_uid=src_port.block.uid, + src_port_name=src_port.name, + dst_block_uid=dst_port.block.uid, + dst_port_name=dst_port.name, + points=None, + ) + + def _apply_boundary_wire_snapshot( + self, + group_uid: str, + boundary_uid: str, + wiring: BoundaryWiringState, + connection_snapshot: ConnectionSnapshot | None, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return + + existing = find_connection_for_boundary(self.project_state, boundary) + if existing is not None: + self._remove_connection(existing, refresh_boundaries=False) + + apply_wiring_state(boundary, wiring) + if boundary.origin == "manual" and wiring.linked_port_uid: + self._remove_auto_boundaries_for_member_port( + group, wiring.linked_port_uid, keep_uid=boundary_uid + ) + if connection_snapshot is not None: + connection = self._add_connection_from_snapshot(connection_snapshot) + if connection is not None: + boundary.linked_connection_uid = self._connection_key(connection) + else: + boundary.linked_connection_uid = "" + + self.view.refresh_visual_groups() + + def boundary_port_flow_label( + self, + group: VisualGroup, + boundary: BoundaryPort, + ) -> str: + """Return the flow label for a boundary (source for In, destination for Out).""" + return boundary_port_label(self.project_state, group, boundary) + + def boundary_proxy_label(self, boundary: BoundaryPort) -> str: + """Return the label drawn on a GroupIn/GroupOut proxy.""" + if boundary.origin == "manual": + return manual_boundary_display_label(boundary) + if boundary.label.strip(): + return boundary.label.strip() + linked = find_port(self.project_state, boundary.linked_port_uid) + if linked is None: + return "" + return str(linked.display_as or linked.name) + + def rename_boundary_port( + self, + group_uid: str, + boundary_uid: str, + new_label: str, + ) -> bool: + """Rename one proxy label (empty string resets automatic label).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return False + trimmed = new_label.strip() + if trimmed == boundary.label: + return False + self.undo_manager.push( + RenameBoundaryPortCommand( + self, + group_uid, + boundary_uid, + boundary.label, + trimmed, + ) + ) + return True + + def rename_visual_group(self, group_uid: str, new_name: str) -> bool: + """Rename a visual group (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + trimmed = new_name.strip() + if not trimmed or trimmed == group.name: + return False + unique_name = self._make_unique_group_name(trimmed, exclude_uid=group_uid) + self.undo_manager.push( + RenameGroupCommand(self, group_uid, group.name, unique_name) + ) + return True + def make_unique_name(self, base_name: str) -> str: """Return ``base_name`` or a suffixed variant that is unique across all blocks. @@ -225,6 +875,94 @@ def is_name_available(self, name: str, current=None) -> bool: # Connection methods # -------------------------------------------------------------------------- + def try_connect_boundary_ports( + self, + port1: PortInstance, + port2: PortInstance, + ) -> bool: + """Complete or wire a manual group boundary instead of a direct connection.""" + if port1 is port2: + return False + + src_group = self._group_containing_member(port1.block.uid) + dst_group = self._group_containing_member(port2.block.uid) + if ( + src_group is not None + and dst_group is not None + and src_group.uid != dst_group.uid + ): + return False + + ports = {port1, port2} + for group in self.project_state.visual_groups: + for boundary in group.boundary_ports: + if boundary.linked_connection_uid: + continue + + internal = ( + find_port(self.project_state, boundary.linked_port_uid) + if boundary.linked_port_uid + else None + ) + external = ( + find_port(self.project_state, boundary.external_port_uid) + if boundary.external_port_uid + else None + ) + + if ( + internal is not None + and external is not None + and ports == {internal, external} + ): + return self._complete_manual_boundary(group.uid, boundary.uid) + + if internal is not None and internal in ports: + external_candidate = port2 if port1 is internal else port1 + if validate_external_link(group, boundary, external_candidate): + return self._wire_manual_boundary_external( + group.uid, + boundary.uid, + external_candidate, + ) + + if external is not None and external in ports: + internal_candidate = port2 if port1 is external else port1 + if self._validate_internal_link( + group, boundary, internal_candidate + ): + return self._wire_manual_boundary_internal( + group.uid, + boundary.uid, + internal_candidate, + ) + return False + + def _complete_manual_boundary(self, group_uid: str, boundary_uid: str) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or not can_complete(self.project_state, boundary): + return False + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + wiring = capture_wiring_state(boundary) + connection_snapshot = self._connection_snapshot_for_wiring( + group, boundary, wiring + ) + if connection_snapshot is None: + return False + self.undo_manager.push( + WireManualBoundaryCommand( + self, + group_uid, + boundary_uid, + before, + (wiring, connection_snapshot), + ) + ) + return True + def add_connection( self, port1: PortInstance, @@ -242,6 +980,8 @@ def add_connection( port2: Second port (input or output). points: Optional list of intermediate waypoints for the wire. """ + if self.try_connect_boundary_ports(port1, port2): + return if not port1.is_compatible(port2): return src_port, dst_port = ( @@ -274,6 +1014,22 @@ def execute_move_resize_block( ) ) + def execute_move_resize_group( + self, + group_uid: str, + old_pos: QPointF, + old_rect: QRectF, + new_pos: QPointF, + new_rect: QRectF, + ) -> None: + if old_pos == new_pos and old_rect == new_rect: + return + self.undo_manager.push( + MoveResizeGroupCommand( + self, group_uid, old_pos, old_rect, new_pos, new_rect + ) + ) + def execute_toggle_orientation(self, block_instance: BlockInstance) -> None: block_item = self.view.get_block_item_from_instance(block_instance) if block_item is None: @@ -284,6 +1040,55 @@ def execute_toggle_orientation(self, block_instance: BlockInstance) -> None: ToggleOrientationCommand(self, block_instance.uid, old_orientation, new_orientation) ) + def execute_toggle_group_orientation(self, group_uid: str) -> None: + group_item = self.view.group_items.get(group_uid) + if group_item is None: + return + old_orientation = group_item.orientation + new_orientation = "flipped" if old_orientation == "normal" else "normal" + self.undo_manager.push( + ToggleGroupOrientationCommand(self, group_uid, old_orientation, new_orientation) + ) + + def execute_toggle_proxy_orientation(self, group_uid: str, boundary_uid: str) -> None: + proxy_item = self.view.proxy_items.get(boundary_uid) + if proxy_item is None: + return + old_orientation = proxy_item.orientation + new_orientation = "flipped" if old_orientation == "normal" else "normal" + self.undo_manager.push( + ToggleProxyOrientationCommand( + self, group_uid, boundary_uid, old_orientation, new_orientation + ) + ) + + def execute_edit_connection_route( + self, + connection: ConnectionInstance, + old_points: list[QPointF] | None, + new_points: list[QPointF] | None, + ) -> None: + from pySimBlocks.gui.undo_redo.commands import routes_equal + + if routes_equal(old_points, new_points): + return + self.undo_manager.push( + EditConnectionRouteCommand(self, connection, old_points, new_points) + ) + + def execute_move_proxy_layout( + self, + group_uid: str, + boundary_uid: str, + old_pos: QPointF, + new_pos: QPointF, + ) -> None: + if old_pos == new_pos: + return + self.undo_manager.push( + MoveProxyLayoutCommand(self, group_uid, boundary_uid, old_pos, new_pos) + ) + def begin_macro(self, text: str) -> None: self.undo_manager.stack.beginMacro(text) @@ -301,6 +1106,10 @@ def make_dirty(self) -> None: self.is_dirty = True self.dirty_changed.emit(True) + def mark_gui_layout_dirty(self) -> None: + """Mark GUI-only layout edits that stay outside the undo/redo stack.""" + self.make_dirty() + def clear_dirty(self) -> None: """Clear the unsaved-changes flag and emit :attr:`dirty_changed`.""" if self.is_dirty: @@ -311,6 +1120,8 @@ def clear(self) -> None: """Reset the project state and diagram view to an empty state.""" self.project_state.clear() self.view.clear_scene() + self.view.view_stack = [] + self.view.view_stack_changed.emit() self.undo_manager.clear() self.clear_dirty() @@ -338,6 +1149,11 @@ def load_project(self, loader: "ProjectLoader") -> None: project files and populates this controller. """ loader.load(self, self.project_state.directory_path) + for group in self.project_state.visual_groups: + self.ensure_group_boundary_proxies(group) + for group in self.project_state.visual_groups: + self.apply_member_layouts(group) + self.view.refresh_visual_groups() # -------------------------------------------------------------------------- @@ -483,7 +1299,12 @@ def _add_block( block_instance.name = self.make_unique_name(block_instance.name) block_instance.resolve_ports() self.project_state.add_block(block_instance) - self.view.add_block(block_instance, block_layout) + layout = dict(block_layout or {}) + self.view.add_block(block_instance, layout) + block_item = self.view.get_block_item_from_instance(block_instance) + if block_item is not None and ("x" in layout or "y" in layout): + self._apply_block_layout(block_item, layout) + self.view.refresh_visual_groups() return block_instance @@ -505,8 +1326,9 @@ def _ensure_logged(self, signals: list[str]) -> None: self.project_state.logging.append(sig) def _remove_block(self, block_instance: BlockInstance) -> None: + block_uid = block_instance.uid for connection in list(self.project_state.get_connections_of_block(block_instance)): - self._remove_connection(connection) + self._remove_connection(connection, refresh_boundaries=False) removed_signals = [ f"{block_instance.name}.outputs.{p.name}" @@ -514,6 +1336,10 @@ def _remove_block(self, block_instance: BlockInstance) -> None: ] self.project_state.logging = [s for s in self.project_state.logging if s not in removed_signals] + for group in self.project_state.visual_groups: + if block_uid in group.members: + self._remove_boundaries_for_member(group, block_uid) + for i in reversed(range(len(self.project_state.plots))): plot = self.project_state.plots[i] if str(plot.get("layout", "")).strip().lower() == "manual": @@ -557,10 +1383,172 @@ def _remove_block(self, block_instance: BlockInstance) -> None: self.project_state.remove_block(block_instance) self.view.remove_block(block_instance) + self._refresh_boundaries_for_member_uids({block_uid}) - def _remove_connection(self, connection: ConnectionInstance) -> None: + def _remove_connection( + self, + connection: ConnectionInstance, + *, + refresh_boundaries: bool = True, + ) -> None: + block_uids = {connection.src_block().uid, connection.dst_block().uid} + self._preserve_boundaries_on_connection_remove(connection) self.project_state.remove_connection(connection) self.view.remove_connection(connection) + if refresh_boundaries: + self._refresh_boundaries_for_member_uids(block_uids) + + def _capture_boundaries_for_connection( + self, + connection: ConnectionInstance, + ) -> list[tuple[str, dict[str, Any]]]: + """Snapshot group boundaries tied to a diagram connection (for undo).""" + key = self._connection_key(connection) + captured: list[tuple[str, dict[str, Any]]] = [] + seen: set[tuple[str, str]] = set() + for group in self.project_state.visual_groups: + for boundary in group.boundary_ports: + linked = find_connection_for_boundary(self.project_state, boundary) + if linked is not connection and boundary.linked_connection_uid != key: + continue + item = (group.uid, boundary.uid) + if item in seen: + continue + seen.add(item) + captured.append((group.uid, copy.deepcopy(boundary.to_dict()))) + return captured + + def _view_is_inside_group(self, view_group_uid: str | None, group: VisualGroup) -> bool: + """Return whether the active view is inside a group or one of its descendants.""" + if view_group_uid is None: + return False + if view_group_uid == group.uid: + return True + child = self.project_state.get_visual_group(view_group_uid) + while child is not None and child.parent_uid: + if child.parent_uid == group.uid: + return True + child = self.project_state.get_visual_group(child.parent_uid) + return False + + def _preserve_boundaries_on_connection_remove( + self, + connection: ConnectionInstance, + ) -> None: + """Keep boundary proxies wired but mark affected boundaries incomplete.""" + key = self._connection_key(connection) + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + view_group_uid = self.view.current_view_group_uid + changed = False + + for group in self.project_state.visual_groups: + members = set(self._group_content_uids_for_group(group)) + src_in = src_uid in members + dst_in = dst_uid in members + if src_in == dst_in: + continue + + internal_port = connection.dst_port if dst_in else connection.src_port + external_port = connection.src_port if dst_in else connection.dst_port + internal_key = port_key(internal_port) + external_key = port_key(external_port) + + boundary = next( + ( + port + for port in group.boundary_ports + if port.linked_connection_uid == key + or ( + port.linked_port_uid == internal_key + and find_connection_for_boundary(self.project_state, port) + is connection + ) + ), + None, + ) + if boundary is None: + continue + + external_group = self._group_containing_member(external_port.block.uid) + is_cross_group = ( + external_group is not None and external_group.uid != group.uid + ) + external_at_root = external_group is None + deleting_inside = self._view_is_inside_group(view_group_uid, group) + + if is_cross_group: + boundary.linked_connection_uid = "" + if boundary.origin == "manual" and boundary.external_port_uid == external_key: + boundary.external_port_uid = "" + elif boundary.origin != "manual": + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + else: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" + changed = True + continue + + if external_at_root and boundary.origin != "manual": + boundary.linked_connection_uid = "" + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + else: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" + changed = True + continue + + if boundary.origin == "manual": + boundary.linked_connection_uid = "" + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + elif external_at_root: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" + else: + if not boundary.linked_port_uid: + boundary.linked_port_uid = internal_key + if not boundary.external_port_uid: + boundary.external_port_uid = external_key + else: + boundary.linked_connection_uid = "" + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + else: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" + + self.ensure_group_boundary_proxies(group) + changed = True + + if changed: + self.view.refresh_visual_groups() + + def _restore_boundary_snapshots( + self, + snapshots: list[tuple[str, dict[str, Any]]], + ) -> None: + for group_uid, boundary_dict in snapshots: + group = self.project_state.get_visual_group(group_uid) + if group is None: + continue + restored = BoundaryPort.from_dict(boundary_dict) + replaced = False + for index, boundary in enumerate(group.boundary_ports): + if boundary.uid == restored.uid: + group.boundary_ports[index] = restored + replaced = True + break + if not replaced: + group.boundary_ports.append(restored) + self.ensure_group_boundary_proxies(group) + self.view.refresh_visual_groups() def _find_block_by_uid(self, block_uid: str) -> BlockInstance | None: for block in self.project_state.blocks: @@ -608,7 +1596,12 @@ def _capture_connection_snapshot(self, connection: ConnectionInstance) -> Connec points=points, ) - def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> ConnectionInstance | None: + def _add_connection_from_snapshot( + self, + snapshot: ConnectionSnapshot, + *, + refresh_boundaries: bool = True, + ) -> ConnectionInstance | None: src_port = self._find_port(snapshot.src_block_uid, snapshot.src_port_name) dst_port = self._find_port(snapshot.dst_block_uid, snapshot.dst_port_name) if src_port is None or dst_port is None: @@ -620,8 +1613,44 @@ def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> Connect connection_instance = ConnectionInstance(src_port, dst_port) self.project_state.add_connection(connection_instance) self.view.add_connection(connection_instance, snapshot.points) + if refresh_boundaries: + self._refresh_boundaries_for_member_uids( + {src_port.block.uid, dst_port.block.uid} + ) return connection_instance + def _set_group_geometry(self, group_uid: str, pos: QPointF, rect: QRectF) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + group_item = self.view.group_items.get(group_uid) + if group_item is None: + layout = dict(group.layout or {}) + layout.update({ + "x": float(pos.x()), + "y": float(pos.y()), + "width": float(rect.width()), + "height": float(rect.height()), + }) + group.layout = layout + return + group_item.apply_geometry(pos, rect) + self.view.on_group_moved(group_item) + + def _set_group_orientation(self, group_uid: str, orientation: str) -> None: + group_item = self.view.group_items.get(group_uid) + if group_item is None: + return + group_item.set_orientation(orientation) + + def _set_proxy_orientation( + self, group_uid: str, boundary_uid: str, orientation: str + ) -> None: + proxy_item = self.view.proxy_items.get(boundary_uid) + if proxy_item is None: + return + proxy_item.set_orientation(orientation) + def _set_block_geometry(self, block_uid: str, pos: QPointF, rect: QRectF) -> None: block = self._find_block_by_uid(block_uid) if block is None: @@ -675,3 +1704,850 @@ def _apply_block_update( self.view.refresh_block_port(block_instance) return removed return [] + + def _create_visual_group( + self, + blocks: list[BlockInstance], + name: str | None = None, + *, + child_group_uids: list[str] | None = None, + parent_uid: str | None = None, + ) -> VisualGroup: + """Create and register a visual group without pushing undo.""" + if parent_uid is None: + parent_uid = self.view.current_view_group_uid + + member_uids = [b.uid for b in blocks] + child_uids = list(dict.fromkeys(child_group_uids or [])) + content_uids = self._group_content_uids(member_uids, child_uids) + + group = VisualGroup( + uid=uuid.uuid4().hex, + name=self._make_unique_group_name(name or "Group"), + members=member_uids, + parent_uid=parent_uid, + layout=self._compute_group_layout(member_uids, child_uids), + boundary_ports=self._build_group_boundary_ports(content_uids), + child_group_uids=child_uids, + member_layouts=self._capture_member_layouts(member_uids), + ) + self.ensure_group_boundary_proxies(group) + self.project_state.visual_groups.append(group) + self._apply_group_creation_side_effects(group) + return group + + def _group_content_uids( + self, + member_uids: list[str], + child_group_uids: list[str] | None = None, + ) -> list[str]: + """Return all block uids contained in a group selection.""" + content: set[str] = set(member_uids) + for child_uid in child_group_uids or []: + child = self.project_state.get_visual_group(child_uid) + if child is None: + continue + content.update(self._group_content_uids(child.members, child.child_group_uids)) + return list(content) + + def _group_content_uids_for_group(self, group: VisualGroup) -> list[str]: + """Return all block uids owned by a group and its descendants.""" + return self._group_content_uids(group.members, group.child_group_uids) + + def _apply_group_creation_side_effects(self, group: VisualGroup) -> None: + """Link a newly created group to its parent and child groups.""" + self._attach_group_to_parent(group) + if group.parent_uid: + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is not None: + moved_uids = set(group.members) + if moved_uids.intersection(parent.members): + parent.members = [ + uid for uid in parent.members if uid not in moved_uids + ] + self._rebuild_group_boundary_ports(parent) + for child_uid in group.child_group_uids: + self._reparent_child_group(child_uid, group.uid) + + def _attach_group_to_parent(self, group: VisualGroup) -> None: + """Register a new group under its parent container.""" + if not group.parent_uid: + return + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is None: + group.parent_uid = None + return + if group.uid not in parent.child_group_uids: + parent.child_group_uids.append(group.uid) + + def _reparent_child_group(self, child_uid: str, new_parent_uid: str) -> None: + """Move a child group under a new parent group.""" + child = self.project_state.get_visual_group(child_uid) + if child is None: + return + old_parent_uid = child.parent_uid + if old_parent_uid and old_parent_uid != new_parent_uid: + old_parent = self.project_state.get_visual_group(old_parent_uid) + if old_parent is not None: + old_parent.child_group_uids = [ + uid for uid in old_parent.child_group_uids if uid != child_uid + ] + child.parent_uid = new_parent_uid + + def _detach_group_from_parent(self, group: VisualGroup) -> None: + """Remove a group from its parent's child list.""" + if not group.parent_uid: + return + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is None: + return + parent.child_group_uids = [ + uid for uid in parent.child_group_uids if uid != group.uid + ] + + def _promote_child_groups(self, group: VisualGroup) -> None: + """Reparent child groups to the dissolved group's parent.""" + grandparent_uid = group.parent_uid + for child_uid in list(group.child_group_uids): + child = self.project_state.get_visual_group(child_uid) + if child is None: + continue + child.parent_uid = grandparent_uid + if grandparent_uid: + grandparent = self.project_state.get_visual_group(grandparent_uid) + if grandparent is not None and child_uid not in grandparent.child_group_uids: + grandparent.child_group_uids.append(child_uid) + + def _block_at_view_level(self, block_uid: str, view_group_uid: str | None) -> bool: + """Return whether a block is a direct member of the active view level.""" + owner = self._group_containing_member(block_uid) + if view_group_uid is None: + return owner is None + return owner is not None and owner.uid == view_group_uid + + def _group_exposing_boundary_for_block( + self, + block_uid: str, + view_group_uid: str | None, + ) -> VisualGroup | None: + """Return the group whose border should expose a member port at this view level.""" + group = self._group_containing_member(block_uid) + if group is None: + return None + while group.parent_uid != view_group_uid: + if group.parent_uid is None: + return group if view_group_uid is None else None + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is None: + return None + group = parent + return group + + def _remove_visual_group(self, group_uid: str) -> bool: + """Remove a visual group without pushing undo.""" + return self.project_state.remove_visual_group(group_uid) + + def _collect_group_delete_targets( + self, group_uid: str + ) -> tuple[list[str], list[str]]: + """Return nested group uids (children first) and all member block uids.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return [], [] + group_uids: list[str] = [] + block_uids: list[str] = [] + for child_uid in group.child_group_uids: + child_groups, child_blocks = self._collect_group_delete_targets(child_uid) + group_uids.extend(child_groups) + block_uids.extend(child_blocks) + block_uids.extend(group.members) + group_uids.append(group_uid) + return group_uids, block_uids + + def _delete_group(self, group_uid: str) -> None: + """Delete a visual group, its descendants, and all member blocks.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + for child_uid in list(group.child_group_uids): + self._delete_group(child_uid) + for member_uid in list(group.members): + block = self._find_block_by_uid(member_uid) + if block is not None: + self._remove_block(block) + self._remove_visual_group(group_uid) + self.view.refresh_visual_groups() + + def _group_containing_member(self, block_uid: str) -> VisualGroup | None: + """Return the visual group that lists ``block_uid`` as a member.""" + for group in self.project_state.visual_groups: + if block_uid in group.members: + return group + return None + + def _can_add_block_to_group(self, group_uid: str, block_uid: str) -> bool: + if self._find_block_by_uid(block_uid) is None: + return False + group = self.project_state.get_visual_group(group_uid) + if group is None or block_uid in group.members: + return False + owner = self._group_containing_member(block_uid) + return owner is None or owner.uid == group_uid + + def _apply_group_snapshot(self, snapshot: dict | None, group_uid: str) -> None: + """Restore a visual group from a serialized snapshot.""" + if snapshot is None: + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + self._remove_visual_group(group_uid) + else: + group = VisualGroup.from_dict(snapshot) + replaced = False + for index, existing in enumerate(self.project_state.visual_groups): + if existing.uid == group.uid: + self.project_state.visual_groups[index] = group + replaced = True + break + if not replaced: + self.project_state.visual_groups.append(group) + self.ensure_group_boundary_proxies(group) + self.view.refresh_visual_groups() + + def _add_member_to_group( + self, + group_uid: str, + block_uid: str, + layout: dict[str, Any], + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None or not self._can_add_block_to_group(group_uid, block_uid): + return False + + group.members.append(block_uid) + group.member_layouts[block_uid] = dict(layout) + self._rebuild_group_boundary_ports(group) + self.view.refresh_visual_groups() + return True + + def _remove_boundaries_for_member(self, group: VisualGroup, block_uid: str) -> None: + """Remove boundary ports tied to a member block.""" + prefix = f"{block_uid}:" + for boundary in list(group.boundary_ports): + if not boundary.linked_port_uid.startswith(prefix): + continue + connection = find_connection_for_boundary(self.project_state, boundary) + if connection is not None: + self._remove_connection(connection, refresh_boundaries=False) + group.boundary_ports = [ + port + for port in group.boundary_ports + if not port.linked_port_uid.startswith(prefix) + ] + + def _remove_member_from_group(self, group_uid: str, block_uid: str) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None or block_uid not in group.members: + return False + + layout = dict(group.member_layouts.get(block_uid, {})) + self._remove_boundaries_for_member(group, block_uid) + group.members = [uid for uid in group.members if uid != block_uid] + group.member_layouts.pop(block_uid, None) + + if not group.members and not group.child_group_uids: + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + self._remove_visual_group(group_uid) + else: + self._rebuild_group_boundary_ports(group) + block = self._find_block_by_uid(block_uid) + if block is not None and layout: + item = self.view.get_block_item_from_instance(block) + if item is not None: + self._apply_block_layout(item, layout) + + self.view.refresh_visual_groups() + return True + + def _capture_member_layouts(self, member_uids: list[str]) -> dict[str, dict[str, Any]]: + """Snapshot block item geometry for internal group view.""" + layouts: dict[str, dict[str, Any]] = {} + for uid in member_uids: + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + layouts[uid] = self._capture_block_layout(block) + return layouts + + def _apply_block_layout(self, block_item, layout: dict[str, Any]) -> None: + """Apply a stored layout snapshot to a block item.""" + block_item.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0)))) + block_item.setRect( + 0, + 0, + float(layout.get("width", block_item.rect().width())), + float(layout.get("height", block_item.rect().height())), + ) + orientation = layout.get("orientation") + if isinstance(orientation, str): + block_item.orientation = orientation + block_item._layout_ports() + self.view.on_block_moved(block_item) + + def apply_member_layouts(self, group: VisualGroup) -> None: + """Apply stored member layouts to visible block items.""" + for uid in group.members: + layout = group.member_layouts.get(uid) + if not layout: + continue + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + self._apply_block_layout(item, layout) + + def save_member_layouts(self, group: VisualGroup) -> None: + """Persist current block item geometry into the group model.""" + for uid in group.members: + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + group.member_layouts[uid] = self._capture_block_layout(block) + + def restore_members_after_ungroup(self, group: VisualGroup) -> None: + """Place ungrouped members and promote child groups to the parent level.""" + self._promote_child_groups(group) + self._detach_group_from_parent(group) + self.apply_member_layouts(group) + + def ensure_group_boundary_proxies(self, group: VisualGroup) -> None: + """Assign proxy ids and default layouts for each group boundary port.""" + inputs = [port for port in group.boundary_ports if port.direction == "input"] + outputs = [port for port in group.boundary_ports if port.direction == "output"] + for index, port in enumerate(inputs): + self._ensure_boundary_proxy(port, "input", index, len(inputs), group) + for index, port in enumerate(outputs): + self._ensure_boundary_proxy(port, "output", index, len(outputs), group) + + def _ensure_boundary_proxy( + self, + boundary: BoundaryPort, + direction: str, + index: int, + total: int, + group: VisualGroup, + ) -> None: + if not boundary.proxy_uid: + boundary.proxy_uid = uuid.uuid4().hex + if not boundary.proxy_layout: + boundary.proxy_layout = self._default_proxy_layout( + direction, index, total, group + ) + + def _default_proxy_layout( + self, + direction: str, + index: int, + total: int, + group: VisualGroup, + ) -> dict[str, float]: + layout = group.layout or {} + height = float(layout.get("height", 100.0)) + y = 20.0 + (index + 1) * max(40.0, height / (total + 1)) + if direction == "input": + return {"x": 10.0, "y": y} + width = float(layout.get("width", 160.0)) + return {"x": max(80.0, width - 66.0), "y": y} + + def _apply_connection_route( + self, + connection: ConnectionInstance, + points: list[QPointF] | None, + ) -> None: + connection_item = self.view.connections.get(connection) + if connection_item is None: + return + if points is None or len(points) < 2: + connection_item.invalidate_manual_route() + else: + connection_item.apply_manual_route(points) + connection_item.update_position() + + def save_proxy_layouts(self, group: VisualGroup) -> None: + """Persist current proxy item positions into the group model.""" + for boundary in group.boundary_ports: + item = self.view.proxy_items.get(boundary.uid) + if item is None: + continue + pos = item.pos() + boundary.proxy_layout = { + "x": float(pos.x()), + "y": float(pos.y()), + "orientation": item.orientation, + } + + def _add_manual_boundary_port( + self, + group_uid: str, + boundary: BoundaryPort, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if any(port.uid == boundary.uid for port in group.boundary_ports): + return + group.boundary_ports.append(boundary) + self.ensure_group_boundary_proxies(group) + self.view.refresh_visual_groups() + + def _restore_boundary_port( + self, + group_uid: str, + boundary: BoundaryPort, + wiring: BoundaryWiringState, + connection_snapshot: ConnectionSnapshot | None, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if any(port.uid == boundary.uid for port in group.boundary_ports): + return + restored = BoundaryPort.from_dict(boundary.to_dict()) + apply_wiring_state(restored, wiring) + group.boundary_ports.append(restored) + self.ensure_group_boundary_proxies(group) + if connection_snapshot is not None: + connection = self._add_connection_from_snapshot(connection_snapshot) + if connection is not None: + restored.linked_connection_uid = self._connection_key(connection) + self.view.refresh_visual_groups() + + def _remove_boundary_port(self, group_uid: str, boundary_uid: str) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is not None: + connection = find_connection_for_boundary(self.project_state, boundary) + if connection is not None: + self._remove_connection(connection, refresh_boundaries=False) + group.boundary_ports = [ + port for port in group.boundary_ports if port.uid != boundary_uid + ] + proxy_item = self.view.proxy_items.pop(boundary_uid, None) + if proxy_item is not None: + self.view.diagram_scene.removeItem(proxy_item) + self.view.refresh_visual_groups() + + def _set_proxy_layout( + self, + group_uid: str, + boundary_uid: str, + pos: QPointF, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = next( + (port for port in group.boundary_ports if port.uid == boundary_uid), + None, + ) + if boundary is None: + return + layout = dict(boundary.proxy_layout or {}) + layout["x"] = float(pos.x()) + layout["y"] = float(pos.y()) + boundary.proxy_layout = layout + proxy_item = self.view.proxy_items.get(boundary_uid) + if proxy_item is not None: + proxy_item.setPos(QPointF(pos)) + for conn_item in self.view.connections.values(): + conn_item.update_position() + + def _set_boundary_label(self, group_uid: str, boundary_uid: str, label: str) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return + trimmed = label.strip() + if not trimmed and boundary.origin == "manual": + trimmed = self._make_unique_boundary_label( + group, + boundary.direction, + exclude_uid=boundary_uid, + ) + boundary.label = trimmed + self.view.refresh_visual_groups() + + def _proxy_default_label(self, direction: str) -> str: + return "In" if direction == "input" else "Out" + + def _make_unique_boundary_label( + self, + group: VisualGroup, + direction: str, + *, + exclude_uid: str | None = None, + ) -> str: + """Return a unique default GroupIn/GroupOut name inside one group.""" + base = self._proxy_default_label(direction) + used: set[str] = set() + for port in group.boundary_ports: + if port.uid == exclude_uid or port.origin != "manual": + continue + if port.direction != direction: + continue + name = port.label.strip() or self._proxy_default_label(port.direction) + used.add(name) + if base not in used: + return base + index = 1 + while f"{base}_{index}" in used: + index += 1 + return f"{base}_{index}" + + def add_manual_boundary_port( + self, + group_uid: str, + direction: str, + pos: QPointF, + ) -> BoundaryPort | None: + """Add a manual GroupIn/GroupOut boundary port to a visual group.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return None + boundary = BoundaryPort( + uid=uuid.uuid4().hex, + direction=direction, + origin="manual", + label=self._make_unique_boundary_label(group, direction), + proxy_uid=uuid.uuid4().hex, + proxy_layout={"x": float(pos.x()), "y": float(pos.y())}, + ) + self.undo_manager.push(AddManualBoundaryCommand(self, group_uid, boundary)) + return boundary + + def remove_boundary_port(self, group_uid: str, boundary_uid: str) -> bool: + """Remove a GroupIn/GroupOut boundary port (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return False + self.undo_manager.push( + RemoveBoundaryPortCommand(self, group_uid, boundary_uid) + ) + return True + + def disconnect_manual_boundary_side( + self, + group_uid: str, + boundary_uid: str, + side: str, + ) -> bool: + """Disconnect one side of an incomplete manual boundary wire (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or boundary.linked_connection_uid: + return False + if side not in ("internal", "external"): + return False + + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + after_wiring = capture_wiring_state(boundary) + if side == "internal": + if not after_wiring.linked_port_uid: + return False + after_wiring.linked_port_uid = "" + else: + if not after_wiring.external_port_uid: + return False + after_wiring.external_port_uid = "" + after_wiring.linked_connection_uid = "" + self.undo_manager.push( + WireManualBoundaryCommand( + self, + group_uid, + boundary_uid, + before, + (after_wiring, None), + ) + ) + return True + + def remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> bool: + """Remove a manual GroupIn/GroupOut boundary port (undoable).""" + return self.remove_boundary_port(group_uid, boundary_uid) + + def _compute_group_layout( + self, + member_uids: list[str], + child_group_uids: list[str] | None = None, + ) -> dict[str, float]: + """Compute a bounding layout for group members and child groups.""" + margin = 16.0 + min_x = float("inf") + min_y = float("inf") + max_x = float("-inf") + max_y = float("-inf") + found = False + + for uid in member_uids: + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + found = True + rect = item.sceneBoundingRect() + min_x = min(min_x, rect.left()) + min_y = min(min_y, rect.top()) + max_x = max(max_x, rect.right()) + max_y = max(max_y, rect.bottom()) + + for child_uid in child_group_uids or []: + item = self.view.group_items.get(child_uid) + if item is None: + continue + found = True + rect = item.sceneBoundingRect() + min_x = min(min_x, rect.left()) + min_y = min(min_y, rect.top()) + max_x = max(max_x, rect.right()) + max_y = max(max_y, rect.bottom()) + + if not found: + return {"x": 0.0, "y": 0.0, "width": 160.0, "height": 100.0} + + return { + "x": float(min_x - margin), + "y": float(min_y - margin), + "width": float(max(max_x - min_x + 2 * margin, 80.0)), + "height": float(max(max_y - min_y + 2 * margin, 50.0)), + } + + def _build_group_boundary_ports(self, member_uids: list[str]) -> list[BoundaryPort]: + """Derive boundary ports from connections crossing group boundaries.""" + members = set(member_uids) + boundary_ports: list[BoundaryPort] = [] + by_internal_port_uid: dict[tuple[str, str], BoundaryPort] = {} + + for connection in self.project_state.connections: + src_in = connection.src_block().uid in members + dst_in = connection.dst_block().uid in members + if src_in == dst_in: + continue + + if dst_in: + direction = "input" + internal_port = connection.dst_port + else: + direction = "output" + internal_port = connection.src_port + + key = (internal_port.block.uid, internal_port.name) + if key in by_internal_port_uid: + continue + + boundary = BoundaryPort( + uid=uuid.uuid4().hex, + direction=direction, + linked_port_uid=f"{internal_port.block.uid}:{internal_port.name}", + origin="auto", + linked_connection_uid=self._connection_key(connection), + ) + by_internal_port_uid[key] = boundary + boundary_ports.append(boundary) + + return boundary_ports + + def _connection_key(self, connection: ConnectionInstance) -> str: + """Build a stable key for one diagram connection.""" + from pySimBlocks.gui.services.group_boundary_service import connection_key + + return connection_key(connection) + + def _remove_auto_boundaries_for_member_port( + self, + group: VisualGroup, + member_port_key: str, + *, + keep_uid: str | None = None, + ) -> None: + """Drop auto boundary ports superseded by a manual port on the same member.""" + group.boundary_ports = [ + port + for port in group.boundary_ports + if not ( + port.origin == "auto" + and port.linked_port_uid == member_port_key + and port.uid != keep_uid + ) + ] + + def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: + """Recompute auto boundary ports, keeping manual ports and stable auto ids.""" + manual_ports = [port for port in group.boundary_ports if port.origin == "manual"] + manual_member_keys = { + port.linked_port_uid + for port in manual_ports + if port.linked_port_uid + } + existing_auto = { + port.linked_port_uid: port + for port in group.boundary_ports + if port.origin == "auto" + } + + rebuilt_auto: list[BoundaryPort] = [] + for port in self._build_group_boundary_ports( + self._group_content_uids_for_group(group) + ): + if port.linked_port_uid in manual_member_keys: + continue + previous = existing_auto.get(port.linked_port_uid) + if previous is not None: + port.uid = previous.uid + port.proxy_uid = previous.proxy_uid + port.proxy_layout = dict(previous.proxy_layout) + port.external_port_uid = previous.external_port_uid + if previous.linked_port_uid: + port.linked_port_uid = previous.linked_port_uid + if not port.linked_connection_uid: + port.linked_connection_uid = previous.linked_connection_uid + rebuilt_auto.append(port) + + rebuilt_keys = {port.linked_port_uid for port in rebuilt_auto} + rebuilt_connection_keys = { + port.linked_connection_uid + for port in rebuilt_auto + if port.linked_connection_uid + } + for port in group.boundary_ports: + if port.origin != "auto": + continue + if port.linked_connection_uid: + continue + if not port.linked_port_uid and not port.external_port_uid: + continue + if port.linked_port_uid in manual_member_keys: + continue + if port.linked_port_uid and port.linked_port_uid in rebuilt_keys: + continue + if ( + not port.linked_port_uid + and port.external_port_uid + and any( + port.external_port_uid.split(":", 1)[0] in key + for key in rebuilt_connection_keys + ) + ): + continue + rebuilt_auto.append(port) + + previous_auto_order = [ + port.linked_port_uid + for port in group.boundary_ports + if port.origin == "auto" and port.linked_port_uid + ] + + def _auto_boundary_order(port: BoundaryPort) -> tuple[int, str]: + try: + return (previous_auto_order.index(port.linked_port_uid), port.uid) + except ValueError: + return (len(previous_auto_order), port.linked_port_uid or port.uid) + + rebuilt_auto.sort(key=_auto_boundary_order) + group.boundary_ports = manual_ports + rebuilt_auto + self.ensure_group_boundary_proxies(group) + + def _group_needs_boundary_refresh( + self, + group: VisualGroup, + block_uids: set[str], + ) -> bool: + """Return whether a group may need boundary ports recomputed.""" + members = set(self._group_content_uids_for_group(group)) + if members.intersection(block_uids): + return True + + for uid in block_uids: + prefix = f"{uid}:" + for port in group.boundary_ports: + if port.linked_port_uid.startswith(prefix): + return True + if port.external_port_uid.startswith(prefix): + return True + + for connection in self.project_state.connections: + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + src_in = src_uid in members + dst_in = dst_uid in members + if src_in != dst_in and (src_uid in block_uids or dst_uid in block_uids): + return True + return False + + def _group_has_stale_boundaries(self, group: VisualGroup) -> bool: + """Return whether auto boundaries no longer match project connections.""" + members = set(self._group_content_uids_for_group(group)) + connection_keys = { + self._connection_key(connection) + for connection in self.project_state.connections + } + for port in group.boundary_ports: + if port.origin == "manual": + continue + if port.linked_port_uid: + block_uid = port.linked_port_uid.split(":", 1)[0] + if block_uid not in members or self._find_block_by_uid(block_uid) is None: + return True + if port.linked_connection_uid and port.linked_connection_uid not in connection_keys: + return True + return False + + def _refresh_boundaries_for_member_uids(self, block_uids: set[str]) -> None: + """Refresh boundary ports for groups affected by block or connection changes.""" + changed = False + for group in self.project_state.visual_groups: + if self._group_needs_boundary_refresh( + group, block_uids + ) or self._group_has_stale_boundaries(group): + self._rebuild_group_boundary_ports(group) + changed = True + if changed: + self.view.refresh_visual_groups() + + def _make_unique_group_name( + self, + base_name: str, + exclude_uid: str | None = None, + ) -> str: + """Return a unique visual group name based on existing groups.""" + existing = { + g.name + for g in self.project_state.visual_groups + if g.uid != exclude_uid + } + if base_name not in existing: + return base_name + i = 1 + while f"{base_name}_{i}" in existing: + i += 1 + return f"{base_name}_{i}" diff --git a/pySimBlocks/gui/services/group_boundary_service.py b/pySimBlocks/gui/services/group_boundary_service.py new file mode 100644 index 0000000..2f396a5 --- /dev/null +++ b/pySimBlocks/gui/services/group_boundary_service.py @@ -0,0 +1,168 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pySimBlocks.gui.models.port_instance import PortInstance +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + +if TYPE_CHECKING: + from pySimBlocks.gui.models.connection_instance import ConnectionInstance + from pySimBlocks.gui.models.project_state import ProjectState + + +def port_key(port: PortInstance) -> str: + """Build a stable key for a simulation port.""" + return f"{port.block.uid}:{port.name}" + + +def parse_port_key(key: str) -> tuple[str, str] | None: + if not key or ":" not in key: + return None + block_uid, port_name = key.split(":", 1) + return block_uid, port_name + + +def find_port(state: ProjectState, key: str) -> PortInstance | None: + parsed = parse_port_key(key) + if parsed is None: + return None + block_uid, port_name = parsed + for block in state.blocks: + if block.uid != block_uid: + continue + for port in block.ports: + if port.name == port_name: + return port + return None + + +def is_manual(boundary: BoundaryPort) -> bool: + return boundary.origin == "manual" + + +def is_complete(boundary: BoundaryPort) -> bool: + return bool(boundary.linked_connection_uid) + + +@dataclass +class BoundaryWiringState: + """Snapshot of a manual boundary wiring state.""" + + linked_port_uid: str = "" + external_port_uid: str = "" + linked_connection_uid: str = "" + + +def capture_wiring_state(boundary: BoundaryPort) -> BoundaryWiringState: + return BoundaryWiringState( + linked_port_uid=boundary.linked_port_uid, + external_port_uid=boundary.external_port_uid, + linked_connection_uid=boundary.linked_connection_uid, + ) + + +def apply_wiring_state(boundary: BoundaryPort, state: BoundaryWiringState) -> None: + boundary.linked_port_uid = state.linked_port_uid + boundary.external_port_uid = state.external_port_uid + boundary.linked_connection_uid = state.linked_connection_uid + + +def validate_internal_link( + group: VisualGroup, + boundary: BoundaryPort, + member_port: PortInstance, + *, + content_uids: set[str] | None = None, +) -> bool: + """Return whether a member port may be wired to a boundary proxy.""" + if is_complete(boundary): + return False + scope = content_uids if content_uids is not None else set(group.members) + if member_port.block.uid not in scope: + return False + if boundary.direction == "input" and member_port.direction != "input": + return False + if boundary.direction == "output" and member_port.direction != "output": + return False + return True + + +def validate_external_link( + group: VisualGroup, + boundary: BoundaryPort, + external_port: PortInstance, +) -> bool: + """Return whether an external port may be wired to a group border port.""" + if is_complete(boundary): + return False + if boundary.origin not in ("manual", "auto"): + return False + if external_port.block.uid in group.members: + return False + if boundary.direction == "input" and external_port.direction != "output": + return False + if boundary.direction == "output" and external_port.direction != "input": + return False + return True + + +def connection_endpoints( + state: ProjectState, + boundary: BoundaryPort, +) -> tuple[PortInstance, PortInstance] | None: + """Return src/dst simulation ports when a manual boundary is ready to complete.""" + member_port = find_port(state, boundary.linked_port_uid) + external_port = find_port(state, boundary.external_port_uid) + if member_port is None or external_port is None: + return None + if boundary.direction == "input": + return external_port, member_port + return member_port, external_port + + +def can_complete(state: ProjectState, boundary: BoundaryPort) -> bool: + if is_complete(boundary): + return False + if boundary.origin not in ("manual", "auto"): + return False + if not boundary.linked_port_uid or not boundary.external_port_uid: + return False + endpoints = connection_endpoints(state, boundary) + if endpoints is None: + return False + src_port, dst_port = endpoints + if not src_port.is_compatible(dst_port): + return False + return True + + +def connection_key(connection: ConnectionInstance) -> str: + """Build a stable key for one diagram connection.""" + return ( + f"{connection.src_block().uid}:{connection.src_port.name}->" + f"{connection.dst_block().uid}:{connection.dst_port.name}" + ) + + +def find_connection_for_boundary( + state: ProjectState, + boundary: BoundaryPort, +) -> ConnectionInstance | None: + endpoints = connection_endpoints(state, boundary) + if endpoints is not None: + src_port, dst_port = endpoints + for connection in state.connections: + if connection.src_port is src_port and connection.dst_port is dst_port: + return connection + + if boundary.linked_connection_uid: + for connection in state.connections: + if connection_key(connection) == boundary.linked_connection_uid: + return connection + return None diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 1865bd2..a81c156 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -23,7 +23,7 @@ from PySide6.QtCore import QPointF -from pySimBlocks.gui.models import BlockInstance +from pySimBlocks.gui.models import BlockInstance, VisualGroup from pySimBlocks.gui.project_controller import ProjectController from pySimBlocks.gui.services.yaml_tools import load_yaml_file from pySimBlocks.gui.undo_redo.commands import ConnectionSnapshot @@ -77,16 +77,20 @@ def load(self, controller: ProjectController, directory: Path): gui_data = project_data.get("gui", {}) layout_blocks, layout_conns, layout_warnings = self._load_layout_data(gui_data) + member_layouts_by_uid = self._collect_group_member_layouts(gui_data) for w in layout_warnings: print(f"[Layout warning] {w}") controller.clear() self._load_simulation(controller, sim_data) - self._load_blocks(controller, diagram_data, layout_blocks) + self._load_blocks( + controller, diagram_data, layout_blocks, member_layouts_by_uid + ) self._load_connections(controller, diagram_data, layout_conns) self._load_logging(controller, sim_data) self._load_plots(controller, sim_data) + self._load_groups(controller, gui_data) controller.clear_dirty() @@ -108,10 +112,11 @@ def _load_blocks( controller: ProjectController, diagram_data: dict, layout_blocks: dict | None = None, + member_layouts_by_uid: dict[str, dict] | None = None, ): """Create block instances and restore their layout metadata.""" positions, position_warnings = self._compute_block_positions( - diagram_data, layout_blocks + diagram_data, layout_blocks, member_layouts_by_uid ) for w in position_warnings: print(f"[Layout blocks warning] {w}") @@ -136,7 +141,11 @@ def _load_blocks( controller.view.drop_event_pos = positions.get(name, QPointF(0, 0)) block_meta = controller.resolve_block_meta(category, block_type) - block = controller._add_block(BlockInstance(block_meta), block_layout) + block_uid = desc.get("uid") + block_instance = BlockInstance(block_meta) + if isinstance(block_uid, str) and block_uid.strip(): + block_instance.uid = block_uid.strip() + block = controller._add_block(block_instance, block_layout) controller.rename_block(block, name) raw_params = desc.get("parameters", {}) @@ -253,6 +262,30 @@ def _load_plots(self, controller: ProjectController, sim_data: dict): plot_data = sim_data.get("plots", []) controller.project_state.plots = plot_data if isinstance(plot_data, list) else [] + def _load_groups(self, controller: ProjectController, gui_data: dict) -> None: + """Load visual groups from GUI-only project data.""" + if not isinstance(gui_data, dict): + controller.project_state.visual_groups = [] + return + + groups_data = gui_data.get("groups", []) + if not isinstance(groups_data, list): + print("[Groups warning] project.yaml gui.groups is invalid, ignored.") + controller.project_state.visual_groups = [] + return + + groups: list[VisualGroup] = [] + for idx, raw in enumerate(groups_data): + if not isinstance(raw, dict): + print(f"[Groups warning] Invalid group entry at index {idx}, ignored.") + continue + group = VisualGroup.from_dict(raw) + if not group.uid: + print(f"[Groups warning] Group at index {idx} has empty uid, ignored.") + continue + groups.append(group) + controller.project_state.visual_groups = groups + def _load_layout_data(self, gui_data: dict) -> tuple[dict, dict, list[str]]: """Extract block and connection layout data from GUI configuration.""" warnings = [] @@ -279,10 +312,28 @@ def _load_layout_data(self, gui_data: dict) -> tuple[dict, dict, list[str]]: return blocks, conns, warnings + def _collect_group_member_layouts(self, gui_data: dict) -> dict[str, dict]: + """Merge member layouts from all visual groups, keyed by block uid.""" + layouts: dict[str, dict] = {} + groups = gui_data.get("groups", []) + if not isinstance(groups, list): + return layouts + for group in groups: + if not isinstance(group, dict): + continue + member_layouts = group.get("member_layouts", {}) + if not isinstance(member_layouts, dict): + continue + for uid, layout in member_layouts.items(): + if isinstance(uid, str) and isinstance(layout, dict): + layouts[uid] = layout + return layouts + def _compute_block_positions( self, diagram_data: dict, layout_blocks: dict | None, + member_layouts_by_uid: dict[str, dict] | None = None, ) -> tuple[dict[str, QPointF], list[str]]: """Compute block positions from saved layout or fallback auto-placement.""" warnings = [] @@ -307,6 +358,18 @@ def _compute_block_positions( continue name = block["name"] + block_uid = block.get("uid") + if ( + isinstance(block_uid, str) + and member_layouts_by_uid + and block_uid in member_layouts_by_uid + ): + entry = member_layouts_by_uid[block_uid] + x_val = entry.get("x") + y_val = entry.get("y") + if isinstance(x_val, (int, float)) and isinstance(y_val, (int, float)): + positions[name] = QPointF(float(x_val), float(y_val)) + continue if layout_blocks and name in layout_blocks: entry = layout_blocks[name] @@ -321,7 +384,11 @@ def _compute_block_positions( ) else: - if layout_blocks is not None: + if layout_blocks is not None and not ( + isinstance(block_uid, str) + and member_layouts_by_uid + and block_uid in member_layouts_by_uid + ): warnings.append( f"Block '{name}' not found in project.yaml gui.layout.blocks, auto-placed." ) diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index fdc5d73..d15991f 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -202,6 +202,7 @@ def _build_blocks_section(project_state: ProjectState) -> list[dict]: "name": b.name, "category": b.meta.category, "type": b.meta.type, + "uid": b.uid, "parameters": params, } ) @@ -231,13 +232,17 @@ def _build_connections_section(project_state: ProjectState) -> tuple[list[dict], def _build_layout_section( block_items: dict[str, BlockItem], conn_name_map: dict[tuple[str, str], str], + hidden_member_uids: set[str] | None = None, ) -> dict: """Build the GUI layout section for a project YAML document.""" data: dict = {"blocks": {}} manual_connections = {} seen = set() + hidden_member_uids = hidden_member_uids or set() for block in block_items.values(): + if block.instance.uid in hidden_member_uids: + continue name = block.instance.name pos = block.pos() data["blocks"][name] = { @@ -278,6 +283,11 @@ def _build_layout_section( return data +def _build_groups_section(project_state: ProjectState) -> list[dict]: + """Build serialized visual groups for the GUI section.""" + return [group.to_dict() for group in project_state.visual_groups] + + def build_project_yaml( project_state: ProjectState, block_items: dict[str, BlockItem] | None = None, @@ -300,7 +310,11 @@ def build_project_yaml( blocks = _build_blocks_section(project_state) connections, conn_name_map = _build_connections_section(project_state) - layout = _build_layout_section(block_items, conn_name_map) + hidden_member_uids: set[str] = set() + for group in project_state.visual_groups: + hidden_member_uids.update(group.members) + layout = _build_layout_section(block_items, conn_name_map, hidden_member_uids) + groups = _build_groups_section(project_state) return { "schema_version": 1, @@ -314,5 +328,6 @@ def build_project_yaml( }, "gui": { "layout": layout, + "groups": groups, }, } diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index cd57b56..df6dbaa 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -20,13 +20,37 @@ from __future__ import annotations +import copy from dataclasses import dataclass from typing import Any from PySide6.QtCore import QPointF, QRectF from PySide6.QtGui import QUndoCommand -from pySimBlocks.gui.models import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, ConnectionInstance, PortInstance, VisualGroup +from pySimBlocks.gui.models.visual_group import BoundaryPort + + +def _clone_route_points(points: list[QPointF] | None) -> list[QPointF] | None: + if points is None: + return None + return [QPointF(point) for point in points] + + +def routes_equal( + left: list[QPointF] | None, + right: list[QPointF] | None, +) -> bool: + if left is None and right is None: + return True + if left is None or right is None: + return False + if len(left) != len(right): + return False + return all( + left_point.x() == right_point.x() and left_point.y() == right_point.y() + for left_point, right_point in zip(left, right) + ) @dataclass @@ -83,6 +107,9 @@ def __init__(self, controller, connection_instance): self._controller = controller self._snapshot = controller._capture_connection_snapshot(connection_instance) self._connection_instance = connection_instance + self._boundary_snapshots = controller._capture_boundaries_for_connection( + connection_instance + ) def redo(self) -> None: if self._connection_instance is not None: @@ -90,7 +117,11 @@ def redo(self) -> None: self._controller.make_dirty() def undo(self) -> None: - self._connection_instance = self._controller._add_connection_from_snapshot(self._snapshot) + self._connection_instance = self._controller._add_connection_from_snapshot( + self._snapshot, + refresh_boundaries=False, + ) + self._controller._restore_boundary_snapshots(self._boundary_snapshots) self._controller.make_dirty() @@ -105,7 +136,12 @@ def __init__(self, controller, block_instance: BlockInstance): for connection in controller.project_state.get_connections_of_block(block_instance) ] self._logging_before = list(controller.project_state.logging) - self._plots_before = [dict(title=p["title"], signals=list(p["signals"])) for p in controller.project_state.plots] + self._plots_before = copy.deepcopy(controller.project_state.plots) + self._group_snapshots = { + group.uid: group.to_dict() + for group in controller.project_state.visual_groups + if block_instance.uid in group.members + } def redo(self) -> None: self._controller._remove_block(self._block_instance) @@ -116,7 +152,12 @@ def undo(self) -> None: for snapshot in self._connections: self._controller._add_connection_from_snapshot(snapshot) self._controller.project_state.logging = list(self._logging_before) - self._controller.project_state.plots = [dict(title=p["title"], signals=list(p["signals"])) for p in self._plots_before] + self._controller.project_state.plots = copy.deepcopy(self._plots_before) + for group_uid, group_snapshot in self._group_snapshots.items(): + self._controller._apply_group_snapshot(group_snapshot, group_uid) + group = self._controller.project_state.get_visual_group(group_uid) + if group is not None: + self._controller.apply_member_layouts(group) self._controller.make_dirty() @@ -156,6 +197,309 @@ def undo(self) -> None: self._controller.make_dirty() +class ToggleGroupOrientationCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, old_orientation: str, new_orientation: str): + super().__init__("Flip Group") + self._controller = controller + self._group_uid = group_uid + self._old_orientation = old_orientation + self._new_orientation = new_orientation + + def redo(self) -> None: + self._controller._set_group_orientation(self._group_uid, self._new_orientation) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_group_orientation(self._group_uid, self._old_orientation) + self._controller.make_dirty() + + +class ToggleProxyOrientationCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + old_orientation: str, + new_orientation: str, + ): + super().__init__("Flip Proxy") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._old_orientation = old_orientation + self._new_orientation = new_orientation + + def redo(self) -> None: + self._controller._set_proxy_orientation( + self._group_uid, self._boundary_uid, self._new_orientation + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_proxy_orientation( + self._group_uid, self._boundary_uid, self._old_orientation + ) + self._controller.make_dirty() + + +class GroupBlocksCommand(QUndoCommand): + def __init__( + self, + controller, + blocks: list[BlockInstance], + name: str | None = None, + *, + child_group_uids: list[str] | None = None, + parent_uid: str | None = None, + ): + super().__init__("Group Blocks") + self._controller = controller + self._blocks = list(blocks) + self._name = name + self._child_group_uids = list(child_group_uids or []) + self._parent_uid = parent_uid + self._group_uid: str | None = None + self._group_snapshot: dict | None = None + self._child_snapshots_before: dict[str, dict] = {} + self._parent_children_before: list[str] | None = None + self._parent_snapshot_before: dict | None = None + + def redo(self) -> None: + if self._group_snapshot is not None: + group = VisualGroup.from_dict(self._group_snapshot) + self._controller.project_state.visual_groups.append(group) + self._group_uid = group.uid + self._controller.ensure_group_boundary_proxies(group) + self._controller._apply_group_creation_side_effects(group) + else: + for child_uid in self._child_group_uids: + child = self._controller.project_state.get_visual_group(child_uid) + if child is not None: + self._child_snapshots_before[child_uid] = child.to_dict() + if self._parent_uid: + parent = self._controller.project_state.get_visual_group(self._parent_uid) + if parent is not None: + self._parent_children_before = list(parent.child_group_uids) + self._parent_snapshot_before = parent.to_dict() + + group = self._controller._create_visual_group( + self._blocks, + self._name, + child_group_uids=self._child_group_uids, + parent_uid=self._parent_uid, + ) + self._group_uid = group.uid + self._group_snapshot = group.to_dict() + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + def undo(self) -> None: + if not self._group_uid: + return + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._group_snapshot = group.to_dict() + self._controller._detach_group_from_parent(group) + if self._group_uid in self._controller.view.view_stack: + self._controller.view.navigate_out_of_group(self._group_uid) + self._controller._remove_visual_group(self._group_uid) + + for child_uid, snapshot in self._child_snapshots_before.items(): + child = VisualGroup.from_dict(snapshot) + replaced = False + for index, existing in enumerate(self._controller.project_state.visual_groups): + if existing.uid == child_uid: + self._controller.project_state.visual_groups[index] = child + replaced = True + break + if not replaced: + self._controller.project_state.visual_groups.append(child) + + if self._parent_uid and self._parent_children_before is not None: + parent = self._controller.project_state.get_visual_group(self._parent_uid) + if parent is not None: + parent.child_group_uids = list(self._parent_children_before) + + if self._parent_snapshot_before is not None: + parent = VisualGroup.from_dict(self._parent_snapshot_before) + for index, existing in enumerate(self._controller.project_state.visual_groups): + if existing.uid == parent.uid: + self._controller.project_state.visual_groups[index] = parent + break + + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + +class UngroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str): + super().__init__("Ungroup") + self._controller = controller + self._group_uid = group_uid + self._group_snapshot: dict | None = None + + def redo(self) -> None: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._group_snapshot = group.to_dict() + if self._group_uid in self._controller.view.view_stack: + self._controller.view.navigate_out_of_group(self._group_uid) + self._controller.restore_members_after_ungroup(group) + self._controller._remove_visual_group(self._group_uid) + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + def undo(self) -> None: + if self._group_snapshot: + group = VisualGroup.from_dict(self._group_snapshot) + self._controller.project_state.visual_groups.append(group) + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + +class DeleteGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str): + super().__init__("Delete Group") + self._controller = controller + self._root_group_uid = group_uid + self._group_snapshots: dict[str, dict] = {} + self._group_uids_order: list[str] = [] + self._members: list[tuple[BlockInstance, dict]] = [] + self._connections: list[ConnectionSnapshot] = [] + + group_uids, block_uids = controller._collect_group_delete_targets(group_uid) + self._group_uids_order = group_uids + for uid in group_uids: + group = controller.project_state.get_visual_group(uid) + if group is not None: + self._group_snapshots[uid] = group.to_dict() + + seen_blocks: set[str] = set() + seen_connections: set[int] = set() + for member_uid in block_uids: + if member_uid in seen_blocks: + continue + seen_blocks.add(member_uid) + block = controller._find_block_by_uid(member_uid) + if block is None: + continue + self._members.append( + (block, controller._capture_block_layout(block)) + ) + for connection in controller.project_state.get_connections_of_block(block): + conn_id = id(connection) + if conn_id in seen_connections: + continue + seen_connections.add(conn_id) + self._connections.append( + controller._capture_connection_snapshot(connection) + ) + + self._logging_before = list(controller.project_state.logging) + self._plots_before = copy.deepcopy(controller.project_state.plots) + + def redo(self) -> None: + self._controller._delete_group(self._root_group_uid) + self._controller.make_dirty() + + def undo(self) -> None: + for block, layout in self._members: + self._controller._add_block(block, layout) + for snapshot in self._connections: + self._controller._add_connection_from_snapshot(snapshot) + self._controller.project_state.logging = list(self._logging_before) + self._controller.project_state.plots = copy.deepcopy(self._plots_before) + for uid in self._group_uids_order: + snapshot = self._group_snapshots.get(uid) + if snapshot is not None: + self._controller._apply_group_snapshot(snapshot, uid) + for uid in self._group_uids_order: + group = self._controller.project_state.get_visual_group(uid) + if group is not None: + self._controller.apply_member_layouts(group) + self._controller.make_dirty() + + +class AddToGroupCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + block_uid: str, + layout: dict[str, Any], + ): + super().__init__("Add to Group") + self._controller = controller + self._group_uid = group_uid + self._block_uid = block_uid + self._layout = dict(layout) + group = controller.project_state.get_visual_group(group_uid) + self._snapshot_before = group.to_dict() if group is not None else None + self._snapshot_after: dict | None = None + + def redo(self) -> None: + self._controller._add_member_to_group( + self._group_uid, self._block_uid, self._layout + ) + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._snapshot_after = group.to_dict() + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._apply_group_snapshot(self._snapshot_before, self._group_uid) + self._controller.make_dirty() + + +class RemoveFromGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, block_uid: str): + super().__init__("Remove from Group") + self._controller = controller + self._group_uid = group_uid + self._block_uid = block_uid + group = controller.project_state.get_visual_group(group_uid) + self._snapshot_before = group.to_dict() if group is not None else None + self._snapshot_after: dict | None = None + + def redo(self) -> None: + self._controller._remove_member_from_group(self._group_uid, self._block_uid) + group = self._controller.project_state.get_visual_group(self._group_uid) + self._snapshot_after = group.to_dict() if group is not None else None + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._apply_group_snapshot(self._snapshot_before, self._group_uid) + self._controller.make_dirty() + + +class MoveResizeGroupCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + old_pos: QPointF, + old_rect: QRectF, + new_pos: QPointF, + new_rect: QRectF, + ): + super().__init__("Move/Resize Group") + self._controller = controller + self._group_uid = group_uid + self._old_pos = QPointF(old_pos) + self._old_rect = QRectF(old_rect) + self._new_pos = QPointF(new_pos) + self._new_rect = QRectF(new_rect) + + def redo(self) -> None: + self._controller._set_group_geometry(self._group_uid, self._new_pos, self._new_rect) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_group_geometry(self._group_uid, self._old_pos, self._old_rect) + self._controller.make_dirty() + + class EditBlockParamsCommand(QUndoCommand): def __init__(self, controller, block_instance: BlockInstance, new_params: dict[str, Any]): super().__init__("Edit Block Parameters") @@ -184,3 +528,238 @@ def undo(self) -> None: for snapshot in self._removed_connections: self._controller._add_connection_from_snapshot(snapshot) self._controller.make_dirty() + + +class EditConnectionRouteCommand(QUndoCommand): + def __init__( + self, + controller, + connection_instance: ConnectionInstance, + old_points: list[QPointF] | None, + new_points: list[QPointF] | None, + ): + super().__init__("Edit Connection Route") + self._controller = controller + self._connection_instance = connection_instance + self._old_points = _clone_route_points(old_points) + self._new_points = _clone_route_points(new_points) + + def redo(self) -> None: + self._controller._apply_connection_route( + self._connection_instance, self._new_points + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._apply_connection_route( + self._connection_instance, self._old_points + ) + self._controller.make_dirty() + + +class AddManualBoundaryCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, boundary: BoundaryPort): + super().__init__("Add Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary = boundary + + def redo(self) -> None: + self._controller._add_manual_boundary_port(self._group_uid, self._boundary) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._remove_boundary_port( + self._group_uid, self._boundary.uid + ) + self._controller.make_dirty() + + +class RemoveBoundaryPortCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, boundary_uid: str): + super().__init__("Remove Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + group = controller.project_state.get_visual_group(group_uid) + boundary = ( + controller._find_boundary_port(group, boundary_uid) if group is not None else None + ) + self._boundary_snapshot = ( + BoundaryPort.from_dict(boundary.to_dict()) if boundary is not None else None + ) + wiring, connection = controller._capture_boundary_wire_snapshot( + group_uid, boundary_uid + ) + self._wiring = wiring + self._connection_snapshot = connection + + def redo(self) -> None: + if self._boundary_snapshot is None: + return + self._controller._remove_boundary_port(self._group_uid, self._boundary_uid) + self._controller.make_dirty() + + def undo(self) -> None: + if self._boundary_snapshot is None: + return + self._controller._restore_boundary_port( + self._group_uid, + self._boundary_snapshot, + self._wiring, + self._connection_snapshot, + ) + self._controller.make_dirty() + + +RemoveManualBoundaryCommand = RemoveBoundaryPortCommand + + +class MoveProxyLayoutCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + old_pos: QPointF, + new_pos: QPointF, + ): + super().__init__("Move Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._old_pos = QPointF(old_pos) + self._new_pos = QPointF(new_pos) + + def redo(self) -> None: + self._controller._set_proxy_layout( + self._group_uid, self._boundary_uid, self._new_pos + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_proxy_layout( + self._group_uid, self._boundary_uid, self._old_pos + ) + self._controller.make_dirty() + + +class RenameGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, old_name: str, new_name: str): + super().__init__("Rename Group") + self._controller = controller + self._group_uid = group_uid + self._old_name = old_name + self._new_name = new_name + + def redo(self) -> None: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is None: + return + group.name = self._new_name + self._controller.view.refresh_visual_groups() + self._controller.view.view_stack_changed.emit() + self._controller.make_dirty() + + def undo(self) -> None: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is None: + return + group.name = self._old_name + self._controller.view.refresh_visual_groups() + self._controller.view.view_stack_changed.emit() + self._controller.make_dirty() + + +class RenameBoundaryPortCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + old_label: str, + new_label: str, + ): + super().__init__("Rename Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._old_label = old_label + self._new_label = new_label + + def redo(self) -> None: + self._controller._set_boundary_label( + self._group_uid, self._boundary_uid, self._new_label + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_boundary_label( + self._group_uid, self._boundary_uid, self._old_label + ) + self._controller.make_dirty() + + +class WireManualBoundaryCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + before: tuple, + after: tuple, + ): + super().__init__("Wire Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._before = before + self._after = after + + def redo(self) -> None: + wiring, connection = self._after + self._controller._apply_boundary_wire_snapshot( + self._group_uid, self._boundary_uid, wiring, connection + ) + self._controller.make_dirty() + + def undo(self) -> None: + wiring, connection = self._before + self._controller._apply_boundary_wire_snapshot( + self._group_uid, self._boundary_uid, wiring, connection + ) + self._controller.make_dirty() + + +class PasteClipboardCommand(QUndoCommand): + def __init__( + self, + controller, + clipboard, + origin: QPointF, + parent_group_uid: str | None = None, + ): + super().__init__("Paste") + self._controller = controller + self._clipboard = clipboard + self._origin = QPointF(origin) + self._parent_group_uid = parent_group_uid + self._result = None + + def redo(self) -> None: + from pySimBlocks.gui.diagram_clipboard import paste_clipboard + + self._result = paste_clipboard( + self._controller, + self._clipboard, + self._origin, + parent_group_uid=self._parent_group_uid, + ) + self._controller.make_dirty() + + def undo(self) -> None: + from pySimBlocks.gui.diagram_clipboard import undo_paste + + if self._result is not None: + undo_paste(self._controller, self._result) + self._controller.make_dirty() diff --git a/pySimBlocks/gui/widgets/block_list.py b/pySimBlocks/gui/widgets/block_list.py index 1384713..b4f4747 100644 --- a/pySimBlocks/gui/widgets/block_list.py +++ b/pySimBlocks/gui/widgets/block_list.py @@ -102,6 +102,8 @@ def on_item_double_clicked(self, item, column): return category, block_type = data + if category == "group_ports": + return meta = self.resolve_block_meta(category, block_type) @@ -148,6 +150,21 @@ def __init__(self, self.search.textChanged.connect(self._apply_filter) + def rebuild(self) -> None: + """Rebuild the category tree after the diagram view context changes.""" + expanded = { + self.tree.topLevelItem(i).text(0): self.tree.topLevelItem(i).isExpanded() + for i in range(self.tree.topLevelItemCount()) + } + self.tree.clear() + for category in self.tree.get_categories(): + category_item = QTreeWidgetItem(self.tree, [category]) + category_item.setExpanded(expanded.get(category, False)) + for block_type in self.tree.get_blocks(category): + block_item = QTreeWidgetItem(category_item, [block_type]) + block_item.setData(0, Qt.UserRole, (category, block_type)) + self._apply_filter(self.search.text()) + # -------------------------------------------------------------------------- # Private Methods diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 0ca0408..01f0e45 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -22,37 +22,32 @@ from typing import TYPE_CHECKING, Any -from PySide6.QtCore import QPointF, Qt, QTimer +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen -from PySide6.QtWidgets import QGraphicsScene, QGraphicsView +from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QInputDialog, QMenu from pySimBlocks.gui.graphics.block_item import BlockItem +from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem, GroupItem +from pySimBlocks.gui.diagram_clipboard import DiagramClipboard, clipboard_has_content +from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem, OrthogonalRoute +from pySimBlocks.gui.graphics.manual_boundary_wire_item import ManualBoundaryWireItem from pySimBlocks.gui.graphics.port_item import PortItem from pySimBlocks.gui.graphics.theme import make_theme +from pySimBlocks.gui.group_ports import GROUP_IN_TYPE, GROUP_OUT_TYPE, GROUP_PORTS_CATEGORY from pySimBlocks.gui.models.block_instance import BlockInstance from pySimBlocks.gui.models.connection_instance import ConnectionInstance +from pySimBlocks.gui.services.group_boundary_service import find_port if TYPE_CHECKING: from pySimBlocks.gui.project_controller import ProjectController class DiagramView(QGraphicsView): - """Interactive Qt graphics view for the block diagram canvas. - - Handles block/connection rendering, drag-and-drop, keyboard shortcuts, - zoom, and mouse-driven wire creation. - - Attributes: - diagram_scene: The underlying QGraphicsScene. - theme: Current visual theme (colours, brushes). - pending_port: Port item waiting for a connection to be completed. - temp_connection: Temporary wire shown while dragging from a port. - copied_block: Most recently copied block, used for paste. - project_controller: Controller coordinating model mutations. - block_items: Mapping from block UID to its visual BlockItem. - connections: Mapping from ConnectionInstance to its visual ConnectionItem. - """ + """Interactive Qt graphics view for the block diagram canvas.""" + + group_view_changed = Signal() + view_stack_changed = Signal() def __init__(self): """Initialize the diagram view and configure scene behavior. @@ -78,13 +73,18 @@ def __init__(self): app.paletteChanged.connect(lambda *_: QTimer.singleShot(0, self._apply_theme_from_system)) self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) - self.pending_port: PortItem | None = None + self.pending_port: PortItem | GroupProxyPortItem | GroupBoundaryPortItem | None = None self.temp_connection: ConnectionItem | None = None - self.copied_block: BlockItem | None = None + self.clipboard: DiagramClipboard | None = None + self.paste_generation = 0 self.drop_event_pos: QPointF = QPointF(0, 0) self.project_controller: ProjectController | None self.block_items: dict[str, BlockItem] = {} + self.group_items: dict[str, GroupItem] = {} + self.proxy_items: dict[str, GroupProxyItem] = {} + self.manual_boundary_wires: dict[str, ManualBoundaryWireItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} + self.view_stack: list[str] = [] self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) @@ -118,6 +118,7 @@ def refresh_block_port(self, block_instance: BlockInstance) -> None: block_item = self.get_block_item_from_instance(block_instance) if block_item: block_item.refresh_ports() + self._refresh_group_port_labels() def remove_block(self, block_instance: BlockInstance) -> None: """Remove the visual block item for the given instance from the scene. @@ -158,6 +159,605 @@ def remove_connection(self, connection_instance: ConnectionInstance) -> None: if connection_item: self.diagram_scene.removeItem(connection_item) + def get_selected_block_instances(self) -> list[BlockInstance]: + """Return block instances currently selected on the diagram.""" + selected = [] + for item in self.diagram_scene.selectedItems(): + if isinstance(item, BlockItem) and item.isVisible(): + selected.append(item.instance) + return selected + + def get_selected_group_uids(self) -> list[str]: + """Return UIDs of all selected visible group items.""" + uids: list[str] = [] + for item in self.diagram_scene.selectedItems(): + if isinstance(item, GroupItem) and item.isVisible(): + uids.append(item.group.uid) + return uids + + def get_selected_group_uid(self) -> str | None: + """Return the UID of a selected group item, if any.""" + uids = self.get_selected_group_uids() + return uids[0] if uids else None + + def group_item_for_block_drop(self, block_item: BlockItem) -> GroupItem | None: + """Return the group under a block's center, for drag-and-drop membership.""" + if self.project_controller is None or self.current_view_group_uid is not None: + return None + center = block_item.sceneBoundingRect().center() + for group_item in self.group_items.values(): + if not group_item.isVisible(): + continue + if group_item.sceneBoundingRect().contains(center): + return group_item + return None + + def try_drop_block_onto_group(self, block_item: BlockItem) -> bool: + """Add a root-level block to the group it was dropped onto.""" + if self.project_controller is None: + return False + target_group = self.group_item_for_block_drop(block_item) + if target_group is None: + return False + layout = self.project_controller._capture_block_layout(block_item.instance) + return self.project_controller.add_block_to_group( + target_group.group.uid, + block_item.instance, + layout, + ) + + @property + def current_view_group_uid(self) -> str | None: + """UID of the innermost group in the current view stack.""" + return self.view_stack[-1] if self.view_stack else None + + def _group_path_uids(self, group_uid: str) -> list[str]: + """Build the root-to-group UID path using parent_group_uid links.""" + if self.project_controller is None: + return [group_uid] + state = self.project_controller.project_state + path: list[str] = [] + uid: str | None = group_uid + while uid: + group = state.get_visual_group(uid) + if group is None: + return [group_uid] + path.append(uid) + uid = group.parent_uid + path.reverse() + return path + + def _activate_view_stack(self) -> None: + """Apply the current view stack to the scene.""" + if self.project_controller is None: + return + if self.view_stack: + group_uid = self.view_stack[-1] + group = self.project_controller.project_state.get_visual_group(group_uid) + if group is None: + self.view_stack = [] + else: + self.project_controller.ensure_group_boundary_proxies(group) + self.refresh_visual_groups() + self.project_controller.apply_member_layouts(group) + self.group_view_changed.emit() + self.view_stack_changed.emit() + return + self.refresh_visual_groups() + self.group_view_changed.emit() + self.view_stack_changed.emit() + + def navigate_to_depth(self, depth: int) -> None: + """Navigate to a breadcrumb depth (0 = root diagram).""" + depth = max(0, min(depth, len(self.view_stack))) + if depth == len(self.view_stack): + return + self._save_active_group_view_state() + self.view_stack = self.view_stack[:depth] + self._activate_view_stack() + + def navigate_out_of_group(self, group_uid: str) -> None: + """Leave a group and its nested views in the navigation stack.""" + if group_uid not in self.view_stack: + return + self.navigate_to_depth(self.view_stack.index(group_uid)) + + def pop_view_level(self) -> None: + """Go up one level in the navigation stack.""" + if not self.view_stack: + return + self.navigate_to_depth(len(self.view_stack) - 1) + + def refresh_visual_groups(self) -> None: + """Sync group items, member visibility, and connection display.""" + if self.project_controller is None: + return + + state = self.project_controller.project_state + all_member_uids: set[str] = set() + for group in state.visual_groups: + all_member_uids.update( + self.project_controller._group_content_uids_for_group(group) + ) + + active_uid = self.current_view_group_uid + active_group = state.get_visual_group(active_uid) if active_uid else None + + known_group_uids = {g.uid for g in state.visual_groups} + for uid in list(self.group_items.keys()): + if uid not in known_group_uids: + item = self.group_items.pop(uid) + self.diagram_scene.removeItem(item) + + for group in state.visual_groups: + item = self.group_items.get(group.uid) + if item is None: + item = GroupItem(group, self) + self.diagram_scene.addItem(item) + self.group_items[group.uid] = item + else: + item.group = group + if group.layout: + orientation = group.layout.get("orientation", "normal") + if orientation in {"normal", "flipped"}: + item.orientation = orientation + item.apply_geometry( + QPointF( + float(group.layout.get("x", 0.0)), + float(group.layout.get("y", 0.0)), + ), + QRectF( + 0, + 0, + float(group.layout.get("width", 160.0)), + float(group.layout.get("height", 100.0)), + ), + ) + else: + item.sync_boundary_ports() + + if active_group is None: + for block_uid, block_item in self.block_items.items(): + block_item.setVisible(block_uid not in all_member_uids) + for group_uid, group_item in self.group_items.items(): + group = state.get_visual_group(group_uid) + group_item.setVisible( + group is not None and group.parent_uid is None + ) + else: + member_set = set(active_group.members) + child_groups = set(active_group.child_group_uids) + for block_uid, block_item in self.block_items.items(): + block_item.setVisible(block_uid in member_set) + for group_uid, group_item in self.group_items.items(): + group_item.setVisible(group_uid in child_groups) + + self._refresh_group_proxies(active_group) + self._refresh_group_port_labels() + + for conn_inst, conn_item in self.connections.items(): + src_uid = conn_inst.src_block().uid + dst_uid = conn_inst.dst_block().uid + visible = self._connection_visible(active_group, src_uid, dst_uid) + + conn_item.setVisible(visible) + if visible: + conn_item.update_position() + + self.refresh_manual_boundary_wires() + + def refresh_manual_boundary_wires(self) -> None: + """Rebuild visual-only wires for incomplete group boundaries.""" + for wire in self.manual_boundary_wires.values(): + self.diagram_scene.removeItem(wire) + self.manual_boundary_wires.clear() + + if self.project_controller is None: + return + + state = self.project_controller.project_state + active_uid = self.current_view_group_uid + + if active_uid: + group = state.get_visual_group(active_uid) + if group is None: + return + for boundary in group.boundary_ports: + if boundary.linked_connection_uid: + continue + if not boundary.linked_port_uid: + continue + if not self._proxy_applies_to_active_group(group, boundary): + continue + member_port = find_port(state, boundary.linked_port_uid) + if member_port is None: + continue + proxy = self.proxy_items.get(boundary.uid) + if proxy is None: + continue + child_uid = ( + self._child_group_uid_for_block(group, member_port.block.uid) + if group.child_group_uids + else None + ) + if child_uid is not None: + child_item = self.group_items.get(child_uid) + if child_item is None: + continue + boundary_uid = child_item.find_boundary_for_member_port( + member_port.block.uid, + member_port.name, + ) + if boundary_uid is None: + continue + member_anchor = lambda item=child_item, uid=boundary_uid: item.get_boundary_anchor(uid) + else: + block_item = self.get_block_item_from_instance(member_port.block) + if block_item is None: + continue + port_item = block_item.get_port_item(member_port.name) + if port_item is None: + continue + member_anchor = port_item.connection_anchor + wire = ManualBoundaryWireItem( + self, + proxy.member_anchor, + member_anchor, + group_uid=active_uid, + boundary_uid=boundary.uid, + side="internal", + ) + self.diagram_scene.addItem(wire) + self.manual_boundary_wires[f"{boundary.uid}:internal"] = wire + return + + for group in state.visual_groups: + if group.parent_uid is not None: + continue + group_item = self.group_items.get(group.uid) + if group_item is None or not group_item.isVisible(): + continue + for boundary in group.boundary_ports: + if boundary.linked_connection_uid: + continue + if not boundary.external_port_uid: + continue + external_port = find_port(state, boundary.external_port_uid) + if external_port is None: + continue + block_item = self.get_block_item_from_instance(external_port.block) + if block_item is None or not block_item.isVisible(): + continue + port_item = block_item.get_port_item(external_port.name) + if port_item is None: + continue + wire = ManualBoundaryWireItem( + self, + lambda item=group_item, port=boundary: item.boundary_anchor_for(port), + port_item.connection_anchor, + group_uid=group.uid, + boundary_uid=boundary.uid, + side="external", + ) + self.diagram_scene.addItem(wire) + self.manual_boundary_wires[f"{boundary.uid}:external"] = wire + + def _refresh_group_port_labels(self) -> None: + """Refresh boundary and proxy labels after connection changes.""" + for group_item in self.group_items.values(): + group_item.refresh_boundary_port_labels() + for proxy_item in self.proxy_items.values(): + proxy_item.update() + + def _proxy_applies_to_active_group( + self, + group, + boundary, + ) -> bool: + """Return whether a proxy belongs to the current internal group view.""" + if not boundary.linked_port_uid: + return True + block_uid = boundary.linked_port_uid.split(":", 1)[0] + if block_uid in group.members: + return True + if group.child_group_uids: + return self._child_group_uid_for_block(group, block_uid) is not None + return False + + def _refresh_group_proxies(self, active_group) -> None: + """Create or hide GroupIn/GroupOut proxy items for the active internal view.""" + if active_group is None: + for item in self.proxy_items.values(): + item.setVisible(False) + return + + active_boundary_uids = { + boundary.uid + for boundary in active_group.boundary_ports + if self._proxy_applies_to_active_group(active_group, boundary) + } + for uid in list(self.proxy_items.keys()): + if uid not in active_boundary_uids: + item = self.proxy_items.pop(uid) + self.diagram_scene.removeItem(item) + + for boundary in active_group.boundary_ports: + if not self._proxy_applies_to_active_group(active_group, boundary): + continue + item = self.proxy_items.get(boundary.uid) + if item is None: + item = GroupProxyItem(boundary, self) + self.diagram_scene.addItem(item) + self.proxy_items[boundary.uid] = item + else: + item.boundary = boundary + if boundary.proxy_layout: + orientation = boundary.proxy_layout.get("orientation", "normal") + if orientation in {"normal", "flipped"}: + item.orientation = orientation + item.setPos( + QPointF( + float(boundary.proxy_layout.get("x", 0.0)), + float(boundary.proxy_layout.get("y", 0.0)), + ) + ) + item._layout_port() + item.setVisible(True) + + def _child_group_uid_for_block(self, parent_group, block_uid: str) -> str | None: + """Return the direct child group uid that contains ``block_uid``.""" + if self.project_controller is None: + return None + for child_uid in parent_group.child_group_uids: + child = self.project_controller.project_state.get_visual_group(child_uid) + if child is None: + continue + if block_uid in self.project_controller._group_content_uids_for_group(child): + return child_uid + return None + + def _connection_visible( + self, + active_group, + src_uid: str, + dst_uid: str, + ) -> bool: + """Decide whether a connection should be drawn at the current view level.""" + if self.project_controller is None: + return True + + if active_group is None: + src_group = self.project_controller._group_exposing_boundary_for_block( + src_uid, None + ) + dst_group = self.project_controller._group_exposing_boundary_for_block( + dst_uid, None + ) + if src_group is not None and dst_group is not None: + return src_group.uid != dst_group.uid + return True + + members = set(active_group.members) + src_in = src_uid in members + dst_in = dst_uid in members + + if active_group.child_group_uids: + content_uids = set( + self.project_controller._group_content_uids_for_group(active_group) + ) + src_in_content = src_uid in content_uids + dst_in_content = dst_uid in content_uids + + if not src_in_content or not dst_in_content: + return src_in_content != dst_in_content + + src_child = self._child_group_uid_for_block(active_group, src_uid) + dst_child = self._child_group_uid_for_block(active_group, dst_uid) + if src_child != dst_child: + return True + if src_child is None: + return src_uid in members and dst_uid in members + return False + + return (src_uid in members and dst_uid in members) or (src_in ^ dst_in) + + def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: + """Return the scene anchor for a port, redirecting through group borders when collapsed.""" + block_uid = port_item.instance.block.uid + port_name = port_item.instance.name + + active_uid = self.current_view_group_uid + if active_uid and self.project_controller is not None: + group = self.project_controller.project_state.get_visual_group(active_uid) + if group is not None: + if group.child_group_uids: + child_uid = self._child_group_uid_for_block(group, block_uid) + if child_uid is not None: + child_item = self.group_items.get(child_uid) + if child_item is not None and child_item.isVisible(): + boundary_uid = child_item.find_boundary_for_member_port( + block_uid, port_name + ) + if boundary_uid is not None: + anchor = child_item.get_boundary_anchor(boundary_uid) + if anchor is not None: + return anchor + + member_anchor = self._manual_proxy_anchor_for_member_port(group, port_item) + if member_anchor is not None: + return member_anchor + external_anchor = self._proxy_anchor_for_external_port(group, port_item) + if external_anchor is not None: + return external_anchor + return port_item.connection_anchor() + + for group_item in self.group_items.values(): + if not group_item.isVisible(): + continue + exposing_group = self.project_controller._group_exposing_boundary_for_block( + block_uid, + self.current_view_group_uid, + ) + if exposing_group is None or exposing_group.uid != group_item.group.uid: + continue + boundary_uid = group_item.find_boundary_for_member_port(block_uid, port_name) + if boundary_uid is None: + break + anchor = group_item.get_boundary_anchor(boundary_uid) + if anchor is not None: + return anchor + + return port_item.connection_anchor() + + def proxy_item_for_port_item(self, port_item: PortItem) -> GroupProxyItem | None: + """Return the proxy that currently owns a port's routed anchor.""" + redirected = self.connection_anchor_for_port_item(port_item) + direct = port_item.connection_anchor() + if ( + abs(redirected.x() - direct.x()) < 0.5 + and abs(redirected.y() - direct.y()) < 0.5 + ): + return None + for proxy in self.proxy_items.values(): + if not proxy.isVisible(): + continue + anchor = proxy.member_anchor() + if ( + abs(anchor.x() - redirected.x()) < 0.5 + and abs(anchor.y() - redirected.y()) < 0.5 + ): + return proxy + return None + + def routing_rect_for_port_item(self, port_item: PortItem) -> QRectF: + """Return the obstacle rectangle used when routing wires to a port.""" + proxy = self.proxy_item_for_port_item(port_item) + if proxy is not None: + return proxy.mapRectToScene(proxy.rect()) + + block_item = port_item.parent_block + block_uid = port_item.instance.block.uid + if self.project_controller is not None and self.current_view_group_uid is None: + exposing = self.project_controller._group_exposing_boundary_for_block( + block_uid, self.current_view_group_uid + ) + if exposing is not None: + group_item = self.group_items.get(exposing.uid) + if group_item is not None and group_item.isVisible(): + return group_item.mapRectToScene(group_item.rect()) + + return block_item.mapRectToScene(block_item.rect()) + + def _manual_proxy_anchor_for_member_port(self, group, port_item: PortItem) -> QPointF | None: + """Route member ports to a proxy while the boundary is incomplete.""" + key = f"{port_item.instance.block.uid}:{port_item.instance.name}" + for boundary in group.boundary_ports: + if boundary.linked_connection_uid: + continue + if boundary.linked_port_uid != key: + continue + proxy = self.proxy_items.get(boundary.uid) + if proxy is None: + return None + return proxy.member_anchor() + return None + + def _proxy_anchor_for_external_port(self, group, port_item: PortItem) -> QPointF | None: + """In internal view, attach crossing wires to the member-facing proxy port.""" + if self.project_controller is None: + return None + + members = set(self.project_controller._group_content_uids_for_group(group)) + port_instance = port_item.instance + for connection in self.project_controller.project_state.connections: + if connection.src_port is not port_instance and connection.dst_port is not port_instance: + continue + + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + src_in = src_uid in members + dst_in = dst_uid in members + if src_in == dst_in: + continue + + if dst_in: + member_uid, member_port_name = dst_uid, connection.dst_port.name + external_port = connection.src_port + else: + member_uid, member_port_name = src_uid, connection.src_port.name + external_port = connection.dst_port + + if external_port is not port_instance: + continue + + linked_key = f"{member_uid}:{member_port_name}" + for boundary in group.boundary_ports: + if boundary.linked_port_uid != linked_key: + continue + proxy = self.proxy_items.get(boundary.uid) + if proxy is None: + return None + return proxy.member_anchor() + return None + + def on_proxy_moved(self, _proxy_item: GroupProxyItem) -> None: + """Refresh wires after a group proxy is moved.""" + for conn_item in self.connections.values(): + conn_item.update_position() + for wire in self.manual_boundary_wires.values(): + wire.update_position() + + def on_connection_route_edited( + self, + connection_item: ConnectionItem, + old_points: list[QPointF] | None, + new_points: list[QPointF] | None, + ) -> None: + """Record a manual wire route edit on the undo/redo stack.""" + if self.project_controller is not None: + self.project_controller.execute_edit_connection_route( + connection_item.instance, + old_points, + new_points, + ) + connection_item.update_position() + + def enter_group(self, group_uid: str) -> None: + """Open the internal view of a visual group.""" + if self.project_controller is None: + return + if self.project_controller.project_state.get_visual_group(group_uid) is None: + return + new_stack = self._group_path_uids(group_uid) + if new_stack == self.view_stack: + return + self._save_active_group_view_state() + self.view_stack = new_stack + self._activate_view_stack() + + def exit_group_view(self) -> None: + """Go up one level in the navigation stack.""" + self.pop_view_level() + + def _save_active_group_view_state(self) -> None: + """Persist block and proxy positions for the active internal group view.""" + if self.project_controller is None or self.current_view_group_uid is None: + return + group = self.project_controller.project_state.get_visual_group( + self.current_view_group_uid + ) + if group is None: + return + self.project_controller.save_member_layouts(group) + self.project_controller.save_proxy_layouts(group) + + def on_group_moved(self, group_item: GroupItem) -> None: + """Refresh wires after a group container is moved.""" + for conn_inst, conn_item in self.connections.items(): + conn_item.update_position() + for wire in self.manual_boundary_wires.values(): + wire.update_position() + def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockItem | None: """Return the visual BlockItem for the given block instance, or None. @@ -169,12 +769,11 @@ def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockIt """ return self.block_items.get(block_instance.uid) - def create_connection_event(self, port: PortItem) -> None: - """Begin a wire-drag interaction from the given port item. - - Args: - port: The port item from which the connection is being drawn. - """ + def create_connection_event( + self, + port: PortItem | GroupProxyPortItem | GroupBoundaryPortItem, + ) -> None: + """Begin a wire-drag interaction from the given port item.""" if not self.pending_port: self.pending_port = port self.temp_connection = ConnectionItem(self.pending_port, None, None) @@ -196,10 +795,22 @@ def on_block_moved(self, block_item: BlockItem) -> None: Args: block_item: The block item that was repositioned. """ + if self.current_view_group_uid and self.project_controller is not None: + group = self.project_controller.project_state.get_visual_group( + self.current_view_group_uid + ) + if ( + group is not None + and block_item.instance.uid in group.members + ): + group.member_layouts[block_item.instance.uid] = ( + self.project_controller._capture_block_layout(block_item.instance) + ) for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): - conn_item.invalidate_manual_route() conn_item.update_position() + for wire in self.manual_boundary_wires.values(): + wire.update_position() def on_block_ports_refreshed(self, block_item: BlockItem) -> None: """Refresh all wire positions after the ports of a block have been updated. @@ -236,7 +847,23 @@ def dropEvent(self, event) -> None: """ self.drop_event_pos = self.mapToScene(event.position().toPoint()) category, block_type = event.mimeData().text().split(":") - self.project_controller.add_block(category, block_type) + if category == GROUP_PORTS_CATEGORY: + if self.current_view_group_uid is None or self.project_controller is None: + return + direction = "input" if block_type == GROUP_IN_TYPE else "output" + self.project_controller.add_manual_boundary_port( + self.current_view_group_uid, + direction, + self.drop_event_pos, + ) + else: + group_uid = self.current_view_group_uid + if group_uid is not None and self.project_controller is not None: + self.project_controller.add_block_in_group_view( + category, block_type, group_uid + ) + else: + self.project_controller.add_block(category, block_type) event.acceptProposedAction() def keyPressEvent(self, event) -> None: @@ -264,16 +891,20 @@ def keyPressEvent(self, event) -> None: # COPY if event.key() == Qt.Key_C and event.modifiers() & Qt.ControlModifier: - selected = [i for i in self.diagram_scene.selectedItems() if isinstance(i, BlockItem)] - if selected: - self.copied_block = selected[0] + if self.project_controller.copy_selection(): + event.accept() return # PASTE if event.key() == Qt.Key_V and event.modifiers() & Qt.ControlModifier: - if self.copied_block: - self.drop_event_pos = self.copied_block.pos() + QPointF(30, 30) - self.project_controller.add_copy_block(self.copied_block.instance) + if clipboard_has_content(self.clipboard): + offset = 30 * (self.paste_generation + 1) + origin = QPointF( + self.clipboard.anchor_x + offset, + self.clipboard.anchor_y + offset, + ) + if self.project_controller.paste_clipboard_at(origin): + event.accept() return # DELETE @@ -290,13 +921,59 @@ def keyPressEvent(self, event) -> None: self.scale_view(1 / 1.15) return - # ROTATE BLOCK + # GROUP / UNGROUP + if ( + event.key() == Qt.Key_G + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier) + ): + self.project_controller.group_selected_blocks() + event.accept() + return + if ( + event.key() == Qt.Key_U + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier) + ): + self.project_controller.ungroup_selected_group() + event.accept() + return + + if event.key() == Qt.Key_Escape and self.view_stack: + self.exit_group_view() + event.accept() + return + + # ROTATE BLOCK / GROUP / PROXY if event.key() == Qt.Key_R and event.modifiers() & Qt.ControlModifier: - selected = [i for i in self.diagram_scene.selectedItems() - if isinstance(i, BlockItem)] - for item in selected: + selected_blocks = [ + i for i in self.diagram_scene.selectedItems() if isinstance(i, BlockItem) + ] + selected_groups = [ + i for i in self.diagram_scene.selectedItems() if isinstance(i, GroupItem) + ] + selected_proxies: list[GroupProxyItem] = [] + seen_proxy_uids: set[str] = set() + for item in self.diagram_scene.selectedItems(): + if isinstance(item, GroupProxyPortItem): + item = item.parent_proxy + if not isinstance(item, GroupProxyItem): + continue + if item.boundary.uid in seen_proxy_uids: + continue + seen_proxy_uids.add(item.boundary.uid) + selected_proxies.append(item) + for item in selected_blocks: self.project_controller.execute_toggle_orientation(item.instance) - return + for item in selected_groups: + self.project_controller.execute_toggle_group_orientation(item.group.uid) + if self.current_view_group_uid is not None: + for item in selected_proxies: + self.project_controller.execute_toggle_proxy_orientation( + self.current_view_group_uid, + item.boundary.uid, + ) + if selected_blocks or selected_groups or selected_proxies: + event.accept() + return # CENTER VIEW if event.key() == Qt.Key_Space and not event.modifiers(): @@ -345,14 +1022,114 @@ def mouseReleaseEvent(self, event) -> None: pos = self.mapToScene(event.position().toPoint()) items = self.diagram_scene.items(pos) - port = next((i for i in items if isinstance(i, PortItem)), None) - if not port: + wire_types = (PortItem, GroupProxyPortItem, GroupBoundaryPortItem) + target = next((item for item in items if isinstance(item, wire_types)), None) + if target is None or target is self.pending_port: self._cancel_temp_connection() return - self.project_controller.add_connection(self.pending_port.instance, port.instance) + if not ( + isinstance(self.pending_port, PortItem) + and isinstance(target, PortItem) + ): + if self.project_controller.try_wire_boundary_endpoints( + self.pending_port, target + ): + self._cancel_temp_connection() + return + self._cancel_temp_connection() + return + + if self.project_controller.try_connect_boundary_ports( + self.pending_port.instance, + target.instance, + ): + self._cancel_temp_connection() + return + + self.project_controller.add_connection(self.pending_port.instance, target.instance) self._cancel_temp_connection() + def contextMenuEvent(self, event) -> None: + """Show diagram context menu for grouping actions.""" + if self.project_controller is None: + super().contextMenuEvent(event) + return + + clicked_proxy = self._proxy_item_at(event) + if clicked_proxy is not None: + self._show_boundary_port_context_menu(clicked_proxy, event.globalPos()) + return + + menu = QMenu(self) + selected_blocks = self.get_selected_block_instances() + selected_group_uids = self.get_selected_group_uids() + clicked_group = self._group_item_at(event) + clicked_block = self._block_item_at(event) + target_group_uid = ( + clicked_group.group.uid if clicked_group is not None else ( + selected_group_uids[0] if selected_group_uids else None + ) + ) + + group_action = menu.addAction("Group") + group_action.setEnabled(len(selected_blocks) + len(selected_group_uids) >= 2) + group_action.triggered.connect( + lambda *_args: self.project_controller.group_selected_blocks() + ) + + if ( + self.current_view_group_uid is None + and target_group_uid is not None + and len(selected_blocks) == 1 + ): + block = selected_blocks[0] + add_to_group = menu.addAction("Add to group") + add_to_group.setEnabled( + self.project_controller._can_add_block_to_group( + target_group_uid, block.uid + ) + ) + add_to_group.triggered.connect( + lambda *_args, uid=target_group_uid, inst=block: ( + self.project_controller.add_block_to_group(uid, inst) + ) + ) + + if target_group_uid is not None: + enter_action = menu.addAction("Enter") + enter_action.triggered.connect( + lambda *_args, uid=target_group_uid: self.enter_group(uid) + ) + rename_action = menu.addAction("Rename") + rename_action.triggered.connect( + lambda *_args, uid=target_group_uid: self._rename_group(uid) + ) + ungroup_action = menu.addAction("Ungroup") + ungroup_action.triggered.connect( + lambda *_args, uid=target_group_uid: self.project_controller.ungroup(uid) + ) + else: + ungroup_action = menu.addAction("Ungroup") + ungroup_action.setEnabled(False) + + if self.current_view_group_uid is not None: + exit_action = menu.addAction("Go up") + exit_action.triggered.connect(self.exit_group_view) + add_in = menu.addAction("Add input") + add_in.triggered.connect(self._add_manual_group_input) + add_out = menu.addAction("Add output") + add_out.triggered.connect(self._add_manual_group_output) + if clicked_block is not None: + remove_from_group = menu.addAction("Remove from group") + remove_from_group.triggered.connect( + lambda *_args, uid=self.current_view_group_uid, block=clicked_block.instance: ( + self.project_controller.remove_block_from_group(uid, block.uid) + ) + ) + + menu.exec(event.globalPos()) + def delete_selected(self) -> None: """Remove all selected blocks and connections from the project.""" selected_items = list(self.diagram_scene.selectedItems()) @@ -360,21 +1137,55 @@ def delete_selected(self) -> None: return self.project_controller.begin_macro("Delete Selection") try: + removed_boundaries: set[tuple[str, str]] = set() for item in selected_items: - if isinstance(item, BlockItem): + boundary_key = self._boundary_key_for_item(item) + if boundary_key is not None: + if boundary_key not in removed_boundaries: + removed_boundaries.add(boundary_key) + self.project_controller.remove_boundary_port(*boundary_key) + continue + if isinstance(item, GroupItem): + self.project_controller.delete_group(item.group.uid) + elif isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) + elif isinstance(item, ManualBoundaryWireItem): + self.project_controller.disconnect_manual_boundary_side( + item.group_uid, + item.boundary_uid, + item.side, + ) elif isinstance(item, ConnectionItem): self.project_controller.remove_connection(item.instance) finally: self.project_controller.end_macro() + def _boundary_key_for_item(self, item) -> tuple[str, str] | None: + """Return (group_uid, boundary_uid) for a selected group boundary item.""" + if isinstance(item, GroupProxyPortItem): + item = item.parent_proxy + if isinstance(item, GroupProxyItem): + if self.current_view_group_uid is None: + return None + return self.current_view_group_uid, item.boundary.uid + if isinstance(item, GroupBoundaryPortItem): + return item.parent_group.group.uid, item.boundary.uid + return None + def clear_scene(self) -> None: """Remove all blocks and connections from the scene and reset state.""" self.diagram_scene.clear() self.block_items.clear() + self.group_items.clear() + self.proxy_items.clear() + for wire in self.manual_boundary_wires.values(): + self.diagram_scene.removeItem(wire) + self.manual_boundary_wires.clear() self.connections.clear() + self.view_stack = [] self.temp_connection = None self.pending_port = None + self.view_stack_changed.emit() def scale_view(self, factor: float) -> None: """Scale the view by ``factor``, clamped to the allowed zoom range. @@ -425,6 +1236,117 @@ def _refresh_theme_items(self) -> None: conn.update_position() conn.update() + for group in self.group_items.values(): + group.update() + group.refresh_boundary_port_labels() + for boundary_item in group.boundary_port_items.values(): + boundary_item.label.setDefaultTextColor(self.theme.text) + + for proxy in self.proxy_items.values(): + proxy.update() + + def _add_manual_group_input(self) -> None: + self._add_manual_group_boundary("input") + + def _add_manual_group_output(self) -> None: + self._add_manual_group_boundary("output") + + def _group_item_at(self, event) -> GroupItem | None: + if hasattr(event, "position"): + view_pos = event.position().toPoint() + else: + view_pos = event.pos() + pos = self.mapToScene(view_pos) + for item in self.diagram_scene.items(pos): + if isinstance(item, GroupItem): + return item + return None + + def _block_item_at(self, event) -> BlockItem | None: + if hasattr(event, "position"): + view_pos = event.position().toPoint() + else: + view_pos = event.pos() + pos = self.mapToScene(view_pos) + for item in self.diagram_scene.items(pos): + if isinstance(item, BlockItem): + return item + return None + + def _proxy_item_at(self, event) -> GroupProxyItem | None: + if hasattr(event, "position"): + view_pos = event.position().toPoint() + else: + view_pos = event.pos() + pos = self.mapToScene(view_pos) + for item in self.diagram_scene.items(pos): + current = item + while current is not None: + if isinstance(current, GroupProxyItem): + return current + current = current.parentItem() + return None + + def _show_boundary_port_context_menu( + self, + proxy_item: GroupProxyItem, + global_pos, + ) -> None: + """Show rename/reset menu for a group In/Out proxy.""" + if self.project_controller is None or self.current_view_group_uid is None: + return + + boundary = proxy_item.boundary + group_uid = self.current_view_group_uid + menu = QMenu(self) + rename_action = menu.addAction("Rename port") + delete_action = menu.addAction("Delete port") + reset_action = menu.addAction("Reset automatic name") + reset_action.setEnabled(bool(boundary.label.strip())) + + action = menu.exec(global_pos) + if action is rename_action: + current = boundary.label if boundary.label.strip() else proxy_item.center_label() + text, ok = QInputDialog.getText( + self, + "Rename Group Port", + "Proxy label:", + text=current, + ) + if ok: + self.project_controller.rename_boundary_port( + group_uid, boundary.uid, text + ) + elif action is delete_action: + self.project_controller.remove_boundary_port(group_uid, boundary.uid) + elif action is reset_action: + self.project_controller.rename_boundary_port(group_uid, boundary.uid, "") + + def _rename_group(self, group_uid: str) -> None: + if self.project_controller is None: + return + group = self.project_controller.project_state.get_visual_group(group_uid) + if group is None: + return + new_name, accepted = QInputDialog.getText( + self, + "Rename Group", + "Group name:", + text=group.name, + ) + if accepted: + self.project_controller.rename_visual_group(group_uid, new_name) + + def _add_manual_group_boundary(self, direction: str) -> None: + if self.project_controller is None or self.current_view_group_uid is None: + return + origin = self.mapToScene(self.viewport().rect().center()) + self.project_controller.add_manual_boundary_port( + self.current_view_group_uid, + direction, + origin, + ) + def _center_on_diagram(self) -> None: """Fit the view to the bounding rect of all scene items with a small margin.""" scene = self.diagram_scene diff --git a/pySimBlocks/gui/widgets/view_navigation_bar.py b/pySimBlocks/gui/widgets/view_navigation_bar.py new file mode 100644 index 0000000..1fd43f7 --- /dev/null +++ b/pySimBlocks/gui/widgets/view_navigation_bar.py @@ -0,0 +1,86 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from typing import Callable + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QWidget, +) + + +class ViewNavigationBar(QWidget): + """Breadcrumb bar for diagram / group view navigation.""" + + navigate_requested = Signal(int) + + ROOT_LABEL = "Diagram" + + def __init__( + self, + resolve_group_name: Callable[[str], str | None], + parent: QWidget | None = None, + ): + super().__init__(parent) + self._resolve_group_name = resolve_group_name + self._view_stack: list[str] = [] + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(6, 4, 6, 4) + self._layout.setSpacing(4) + self._layout.addStretch(1) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + def set_view_stack(self, stack_uids: list[str]) -> None: + """Rebuild breadcrumb buttons from the current view stack.""" + self._view_stack = list(stack_uids) + + while self._layout.count(): + item = self._layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + self._add_segment_button(self.ROOT_LABEL, depth=0, enabled=len(stack_uids) > 0) + + for depth, group_uid in enumerate(stack_uids, start=1): + self._layout.addWidget(self._separator()) + name = self._resolve_group_name(group_uid) or "Group" + is_current = depth == len(stack_uids) + self._add_segment_button(name, depth=depth, enabled=not is_current) + + self._layout.addStretch(1) + + def _separator(self) -> QLabel: + label = QLabel("›", self) + font = QFont(label.font()) + if font.pointSize() > 0: + font.setPointSize(font.pointSize() + 2) + font.setBold(True) + label.setFont(font) + label.setStyleSheet("color: palette(windowText); padding: 0 4px;") + label.setAlignment(Qt.AlignCenter) + return label + + def _add_segment_button(self, text: str, depth: int, enabled: bool) -> None: + button = QPushButton(text, self) + button.setFlat(True) + button.setCursor(Qt.PointingHandCursor if enabled else Qt.ArrowCursor) + button.setEnabled(enabled) + if not enabled: + font = button.font() + font.setBold(True) + button.setFont(font) + if enabled: + button.clicked.connect(lambda _checked=False, d=depth: self.navigate_requested.emit(d)) + self._layout.addWidget(button) diff --git a/tests/gui/test_diagram_clipboard.py b/tests/gui/test_diagram_clipboard.py new file mode 100644 index 0000000..e242fff --- /dev/null +++ b/tests/gui/test_diagram_clipboard.py @@ -0,0 +1,296 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.diagram_clipboard import ( + capture_groups_clipboard, + capture_proxies_clipboard, + capture_selection_clipboard, + clipboard_has_content, + paste_clipboard, + undo_paste, +) +from pySimBlocks.gui.main_window import MainWindow + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def _select_items(view, *items): + view.diagram_scene.clearSelection() + for item in items: + item.setSelected(True) + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _nested_control_loop(controller): + sum_err = controller.add_block("operators", "sum") + controller_block = controller.add_block("controllers", "state_feedback") + sum_plant = controller.add_block("operators", "sum") + system = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([sum_err, controller_block], name="Regulator") + plant = controller.group_blocks([sum_plant, system], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + return control_loop, regulator, plant, [sum_err, controller_block, sum_plant, system] + + +def test_copy_paste_nested_group_preserves_hierarchy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + control_loop, regulator, plant, blocks = _nested_control_loop(controller) + _select_items(view, view.group_items[control_loop.uid]) + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert len(clipboard.groups) == 3 + assert set(clipboard.root_group_uids) == {control_loop.uid} + + origin = QPointF( + float(control_loop.layout.get("x", 0.0)) + 120.0, + float(control_loop.layout.get("y", 0.0)) + 80.0, + ) + result = paste_clipboard(controller, clipboard, origin) + assert len(result.group_uids) == 3 + assert len(result.blocks) == 4 + + pasted_roots = [ + group + for group in controller.project_state.visual_groups + if group.uid in result.group_uids and group.parent_uid is None + ] + assert len(pasted_roots) == 1 + pasted_root = pasted_roots[0] + assert len(pasted_root.child_group_uids) == 2 + + child_names = { + controller.project_state.get_visual_group(uid).name + for uid in pasted_root.child_group_uids + } + assert child_names == {"Regulator_1", "Plant_1"} + + pasted_block_uids = {block.uid for block in result.blocks} + for child_uid in pasted_root.child_group_uids: + child = controller.project_state.get_visual_group(child_uid) + assert child is not None + assert set(child.members).issubset(pasted_block_uids) + + +def test_copy_paste_multiple_root_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="GroupA") + group_b = controller.group_blocks([c, d], name="GroupB") + + clipboard = capture_groups_clipboard(controller, [group_a.uid, group_b.uid]) + assert clipboard is not None + assert set(clipboard.root_group_uids) == {group_a.uid, group_b.uid} + assert len(clipboard.groups) == 2 + assert len(clipboard.blocks) == 4 + + result = paste_clipboard(controller, clipboard, QPointF(400.0, 200.0)) + pasted_roots = [ + controller.project_state.get_visual_group(uid) + for uid in result.group_uids + if controller.project_state.get_visual_group(uid).parent_uid is None + ] + assert len(pasted_roots) == 2 + assert {group.name for group in pasted_roots} == {"GroupA_1", "GroupB_1"} + + +def test_copy_paste_blocks_only(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + controller.add_connection(_first_port(a, "output"), _first_port(b, "input")) + + _select_items(view, view.get_block_item_from_instance(a), view.get_block_item_from_instance(b)) + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert not clipboard.groups + assert len(clipboard.blocks) == 2 + assert len(clipboard.connections) == 1 + + before_blocks = len(controller.project_state.blocks) + result = paste_clipboard(controller, clipboard, QPointF(300.0, 100.0)) + assert len(controller.project_state.blocks) == before_blocks + 2 + assert len(result.connections) == 1 + + +def test_paste_nested_group_undo(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + control_loop, _regulator, _plant, member_blocks = _nested_control_loop(controller) + clipboard = capture_groups_clipboard(controller, [control_loop.uid]) + result = paste_clipboard(controller, clipboard, QPointF(500.0, 300.0)) + + assert len(controller.project_state.visual_groups) == 6 + assert len(controller.project_state.blocks) == 8 + + undo_paste(controller, result) + assert len(controller.project_state.visual_groups) == 3 + assert len(controller.project_state.blocks) == 4 + for block in member_blocks: + assert controller._find_block_by_uid(block.uid) is not None + + +def test_paste_group_inside_parent_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + parent = controller.group_blocks([c], child_group_uids=[inner.uid], name="Parent") + + clipboard = capture_groups_clipboard(controller, [inner.uid]) + view.enter_group(parent.uid) + + result = paste_clipboard( + controller, + clipboard, + QPointF(200.0, 150.0), + parent_group_uid=parent.uid, + ) + pasted_groups = [ + controller.project_state.get_visual_group(uid) for uid in result.group_uids + ] + assert len(pasted_groups) == 1 + assert pasted_groups[0].parent_uid == parent.uid + assert pasted_groups[0].uid in parent.child_group_uids + + +def test_copy_paste_manual_in_out_proxies(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b], name="Group") + view.enter_group(group.uid) + + first = controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + second = controller.add_manual_boundary_port(group.uid, "output", QPointF(120.0, 50.0)) + _select_items( + view, + view.proxy_items[first.uid], + view.proxy_items[second.uid], + ) + + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert clipboard_has_content(clipboard) + assert len(clipboard.boundary_ports) == 2 + assert not clipboard.blocks + + manual_before = sum( + 1 for port in group.boundary_ports if port.origin == "manual" + ) + result = paste_clipboard(controller, clipboard, QPointF(70.0, 80.0)) + assert len(result.boundary_ports) == 2 + + group = controller.project_state.get_visual_group(group.uid) + manual_after = [port for port in group.boundary_ports if port.origin == "manual"] + assert len(manual_after) == manual_before + 2 + pasted_uids = {uid for _, uid in result.boundary_ports} + pasted = sorted( + [port for port in manual_after if port.uid in pasted_uids], + key=lambda port: port.proxy_layout.get("x", 0.0), + ) + assert {port.direction for port in pasted} == {"input", "output"} + assert {port.label for port in pasted} == {"In_1", "Out_1"} + assert pasted[0].proxy_layout["x"] == 70.0 + assert pasted[0].proxy_layout["y"] == 80.0 + assert pasted[1].proxy_layout["x"] == 150.0 + assert pasted[1].proxy_layout["y"] == 80.0 + assert all(port.uid in view.proxy_items for port in pasted) + + undo_paste(controller, result) + group = controller.project_state.get_visual_group(group.uid) + assert len([port for port in group.boundary_ports if port.origin == "manual"]) == manual_before + + +def test_copy_paste_proxy_with_member_block_preserves_internal_link(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b], name="Group") + view.enter_group(group.uid) + + boundary = controller.add_manual_boundary_port(group.uid, "input", QPointF(30.0, 40.0)) + gain_in = view.get_block_item_from_instance(b).get_port_item(_first_port(b, "input").name) + assert controller.try_wire_boundary_endpoints( + view.proxy_items[boundary.uid].port_item, + gain_in, + ) + + _select_items( + view, + view.proxy_items[boundary.uid], + view.get_block_item_from_instance(b), + ) + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert len(clipboard.boundary_ports) == 1 + assert len(clipboard.blocks) == 1 + + result = paste_clipboard(controller, clipboard, QPointF(60.0, 70.0)) + assert len(result.boundary_ports) == 1 + assert len(result.blocks) == 1 + + pasted_boundary_uid = result.boundary_ports[0][1] + pasted_block_uid = result.blocks[0].uid + group = controller.project_state.get_visual_group(group.uid) + pasted_boundary = next( + port for port in group.boundary_ports if port.uid == pasted_boundary_uid + ) + assert pasted_boundary.linked_port_uid == f"{pasted_block_uid}:{_first_port(b, 'input').name}" + + +def test_paste_proxies_requires_internal_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b], name="Group") + view.enter_group(group.uid) + boundary = controller.add_manual_boundary_port(group.uid, "input", QPointF(20.0, 30.0)) + clipboard = capture_proxies_clipboard(controller, [view.proxy_items[boundary.uid]]) + view.exit_group_view() + + assert controller.paste_clipboard_at(QPointF(50.0, 60.0)) is False + result = paste_clipboard(controller, clipboard, QPointF(50.0, 60.0), parent_group_uid=None) + assert not result.boundary_ports diff --git a/tests/gui/test_group_boundary_labels.py b/tests/gui/test_group_boundary_labels.py new file mode 100644 index 0000000..1e4a27a --- /dev/null +++ b/tests/gui/test_group_boundary_labels.py @@ -0,0 +1,99 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.group_boundary_labels import ( + boundary_port_label, + manual_boundary_display_label, +) +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _port_named(block, name: str): + for port in block.ports: + if port.name == name: + return port + raise AssertionError(f"No port named {name} for block {block.name}") + + +def _create_window(qtbot, tmp_path): + from pySimBlocks.gui.main_window import MainWindow + + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def test_boundary_port_label_input_shows_external_source(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + _first_port(src, "output").display_as = "u" + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([gain, out], name="G") + boundary = next(p for p in group.boundary_ports if p.direction == "input") + + assert boundary_port_label(controller.project_state, group, boundary) == "u" + + +def test_boundary_port_label_output_shows_external_destination(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + sink = controller.add_block("operators", "sum") + _port_named(sink, "in1").display_as = "y" + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _port_named(sink, "in1")) + + group = controller.group_blocks([src, gain], name="G") + boundary = next(p for p in group.boundary_ports if p.direction == "output") + + assert boundary_port_label(controller.project_state, group, boundary) == "y" + + +def test_boundary_port_label_manual_defaults_to_in_or_out(): + boundary = BoundaryPort(uid="b1", direction="input", origin="manual") + group = VisualGroup(uid="g1", name="G", members=["m1"], boundary_ports=[boundary]) + + class _State: + connections = [] + + assert boundary_port_label(_State(), group, boundary) == "In" + assert manual_boundary_display_label(boundary) == "In" + + out = BoundaryPort(uid="b2", direction="output", origin="manual") + assert manual_boundary_display_label(out) == "Out" + + +def test_boundary_port_label_manual_keeps_proxy_name_when_internally_wired(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks([src, gain]) + view.enter_group(group.uid) + boundary = controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + assert boundary.label == "In" + assert boundary_port_label(controller.project_state, group, boundary) == "In" + assert controller.boundary_proxy_label(boundary) == "In" diff --git a/tests/gui/test_group_orientation.py b/tests/gui/test_group_orientation.py new file mode 100644 index 0000000..bf4920e --- /dev/null +++ b/tests/gui/test_group_orientation.py @@ -0,0 +1,136 @@ +from PySide6.QtCore import Qt, QPointF +from PySide6.QtGui import QKeyEvent + +from pySimBlocks.gui.main_window import MainWindow + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No port with direction={direction} for block {block.name}") + + +def test_ctrl_r_flips_group_display_without_touching_members(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + group = controller.group_blocks([src, gain], name="Loop") + + src_item = view.get_block_item_from_instance(src) + gain_item = view.get_block_item_from_instance(gain) + group_item = view.group_items[group.uid] + assert group_item.orientation == "normal" + + output_ports = [ + item + for item in group_item.boundary_port_items.values() + if item.boundary.direction == "output" + ] + assert output_ports + assert output_ports[0].pos().x() == group_item.rect().width() + + view.diagram_scene.clearSelection() + group_item.setSelected(True) + view.keyPressEvent( + QKeyEvent( + QKeyEvent.Type.KeyPress, Qt.Key.Key_R, Qt.KeyboardModifier.ControlModifier + ) + ) + + assert group_item.orientation == "flipped" + assert group.layout["orientation"] == "flipped" + assert src_item.orientation == "normal" + assert gain_item.orientation == "normal" + flipped_outputs = [ + item + for item in group_item.boundary_port_items.values() + if item.boundary.direction == "output" + ] + assert flipped_outputs + assert flipped_outputs[0].pos().x() == 0 + + +def test_flipped_group_wire_exits_away_from_anchor(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + group = controller.group_blocks([src, gain], name="Loop") + + group_item = view.group_items[group.uid] + connection = next( + conn + for conn in controller.project_state.connections + if {conn.src_block().uid, conn.dst_block().uid} == {gain.uid, out.uid} + ) + conn_item = view.connections[connection] + + anchor_before, outbound_before = conn_item.route.points[:2] + assert outbound_before.x() > anchor_before.x() + + view.diagram_scene.clearSelection() + group_item.setSelected(True) + view.keyPressEvent( + QKeyEvent( + QKeyEvent.Type.KeyPress, Qt.Key.Key_R, Qt.KeyboardModifier.ControlModifier + ) + ) + + anchor_after, outbound_after = conn_item.route.points[:2] + assert outbound_after.x() < anchor_after.x() + + +def test_ctrl_r_flips_proxy_display_without_touching_members(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + group = controller.group_blocks([gain, out], name="Loop") + view.enter_group(group.uid) + + boundary = controller.add_manual_boundary_port( + group.uid, "input", QPointF(50.0, 60.0) + ) + proxy = view.proxy_items[boundary.uid] + gain_item = view.get_block_item_from_instance(gain) + assert gain_item.orientation == "normal" + assert proxy.port_item.pos().x() == proxy.rect().width() + + view.diagram_scene.clearSelection() + proxy.setSelected(True) + view.keyPressEvent( + QKeyEvent( + QKeyEvent.Type.KeyPress, Qt.Key.Key_R, Qt.KeyboardModifier.ControlModifier + ) + ) + + assert proxy.orientation == "flipped" + assert boundary.proxy_layout["orientation"] == "flipped" + assert gain_item.orientation == "normal" + assert proxy.port_item.pos().x() == 0 + + + diff --git a/tests/gui/test_manual_boundary_wiring.py b/tests/gui/test_manual_boundary_wiring.py new file mode 100644 index 0000000..88e0510 --- /dev/null +++ b/tests/gui/test_manual_boundary_wiring.py @@ -0,0 +1,1124 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.graphics.manual_boundary_wire_item import ManualBoundaryWireItem +from pySimBlocks.gui.main_window import MainWindow + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _manual_input_boundary(controller, view, group): + view.enter_group(group.uid) + return controller.add_manual_boundary_port( + group.uid, "input", QPointF(40.0, 50.0) + ) + + +def _manual_output_boundary(controller, view, group): + view.enter_group(group.uid) + return controller.add_manual_boundary_port( + group.uid, "output", QPointF(120.0, 50.0) + ) + + +def test_delete_incomplete_internal_boundary_wire_keeps_proxy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.uid in view.proxy_items + assert f"{boundary.uid}:internal" not in view.manual_boundary_wires + + +def test_delete_incomplete_internal_boundary_wire_keeps_output_proxy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.uid in view.proxy_items + assert f"{boundary.uid}:internal" not in view.manual_boundary_wires + + +def test_delete_incomplete_external_boundary_wire_keeps_group_port(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + + controller.remove_connection(controller.project_state.connections[0]) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + assert isinstance(wire, ManualBoundaryWireItem) + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + assert boundary.uid in view.proxy_items + + +def test_delete_incomplete_external_boundary_wire_keeps_output_group_port(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "input") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + + controller.remove_connection(controller.project_state.connections[0]) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + assert isinstance(wire, ManualBoundaryWireItem) + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + assert boundary.uid in view.proxy_items + + +def test_delete_completed_group_connection_keeps_proxy_and_shows_dashed_wire( + qtbot, tmp_path +): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + connection = controller.project_state.connections[0] + assert boundary.linked_connection_uid + + controller.remove_connection(connection) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert not boundary.linked_connection_uid + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + + view.enter_group(group.uid) + assert boundary.uid in view.proxy_items + assert view.proxy_items[boundary.uid].isVisible() + + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" not in view.manual_boundary_wires + + +def test_delete_completed_group_connection_keeps_output_proxy_and_dashed_wire( + qtbot, tmp_path +): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "input") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + connection = controller.project_state.connections[0] + assert boundary.linked_connection_uid + + controller.remove_connection(connection) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert not boundary.linked_connection_uid + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + + view.enter_group(group.uid) + assert boundary.uid in view.proxy_items + assert view.proxy_items[boundary.uid].isVisible() + + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" not in view.manual_boundary_wires + + +def test_delete_block_to_group_connection_keeps_internal_dashed_wire(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + member = controller.add_block("operators", "sum") + group = controller.group_blocks([member, controller.add_block("operators", "gain")], name="ControlLoop") + controller.add_connection(_first_port(step, "output"), _port_named(member, "in1")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert len(view.manual_boundary_wires) == 0 + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.direction == "input") + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + +def test_delete_block_to_group_output_connection_keeps_internal_dashed_wire(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + sink = controller.add_block("operators", "sum") + member = controller.add_block("operators", "gain") + group = controller.group_blocks( + [member, controller.add_block("sources", "constant")], + name="ControlLoop", + ) + controller.add_connection(_first_port(member, "output"), _first_port(sink, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert len(view.manual_boundary_wires) == 0 + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.direction == "output") + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + +def test_delete_inside_group_keeps_external_reconnect_state(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks( + [controller.add_block("sources", "constant"), gain], + name="ControlLoop", + ) + controller.add_connection(_first_port(step, "output"), _first_port(gain, "input")) + view.refresh_visual_groups() + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.origin == "auto") + connection = next(iter(controller.project_state.connections)) + + view.enter_group(group.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.origin == "auto" + assert boundary.external_port_uid + assert boundary.linked_port_uid == "" + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" in view.manual_boundary_wires + + border_port = view.group_items[group.uid].boundary_port_items[boundary.uid] + step_port = view.get_block_item_from_instance(step).get_port_item( + _first_port(step, "output").name + ) + assert controller.try_wire_boundary_endpoints(step_port, border_port) + assert len(controller.project_state.connections) == 0 + + view.enter_group(group.uid) + proxy_port = view.proxy_items[boundary.uid].port_item + gain_port = view.get_block_item_from_instance(gain).get_port_item( + _first_port(gain, "input").name + ) + assert controller.try_wire_boundary_endpoints(proxy_port, gain_port) + assert len(controller.project_state.connections) == 1 + + controller.remove_connection(controller.project_state.connections[0]) + view.refresh_visual_groups() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.external_port_uid + assert boundary.linked_port_uid == "" + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" in view.manual_boundary_wires + + assert controller.try_wire_boundary_endpoints(step_port, border_port) + view.enter_group(group.uid) + assert controller.try_wire_boundary_endpoints(proxy_port, gain_port) + assert len(controller.project_state.connections) == 1 + + +def test_delete_inside_group_keeps_external_reconnect_state_for_output(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + sink = controller.add_block("operators", "sum") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks( + [controller.add_block("sources", "constant"), gain], + name="ControlLoop", + ) + controller.add_connection(_first_port(gain, "output"), _first_port(sink, "input")) + view.refresh_visual_groups() + + group = controller.project_state.get_visual_group(group.uid) + boundary = next( + port for port in group.boundary_ports + if port.origin == "auto" and port.direction == "output" + ) + connection = next(iter(controller.project_state.connections)) + + view.enter_group(group.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.origin == "auto" + assert boundary.external_port_uid + assert boundary.linked_port_uid == "" + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" in view.manual_boundary_wires + + border_port = view.group_items[group.uid].boundary_port_items[boundary.uid] + sink_port = view.get_block_item_from_instance(sink).get_port_item( + _first_port(sink, "input").name + ) + assert controller.try_wire_boundary_endpoints(border_port, sink_port) + assert len(controller.project_state.connections) == 0 + + view.enter_group(group.uid) + proxy_port = view.proxy_items[boundary.uid].port_item + gain_port = view.get_block_item_from_instance(gain).get_port_item( + _first_port(gain, "output").name + ) + assert controller.try_wire_boundary_endpoints(gain_port, proxy_port) + assert len(controller.project_state.connections) == 1 + + +def test_delete_internal_dashed_after_root_delete_keeps_nothing(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + controller.remove_connection(controller.project_state.connections[0]) + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + + +def test_delete_internal_dashed_after_root_delete_keeps_nothing_for_output(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "input") + ) + controller.remove_connection(controller.project_state.connections[0]) + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + + +def test_delete_auto_boundary_connection_keeps_proxy_in_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + controller.add_connection(_first_port(external, "output"), _first_port(gain, "input")) + + group = controller.project_state.get_visual_group(group.uid) + auto_boundary = next( + port for port in group.boundary_ports if port.origin == "auto" + ) + connection = next( + connection + for connection in controller.project_state.connections + if connection.dst_block().uid == gain.uid + and connection.src_block().uid == external.uid + ) + controller.remove_connection(connection) + + boundary = controller._find_boundary_port(group, auto_boundary.uid) + assert boundary is not None + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{auto_boundary.uid}:internal" in view.manual_boundary_wires + assert auto_boundary.uid in view.proxy_items + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert not view.manual_boundary_wires + + +def test_delete_auto_output_boundary_connection_keeps_proxy_in_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + controller.add_connection(_first_port(gain, "output"), _first_port(external, "input")) + + group = controller.project_state.get_visual_group(group.uid) + auto_boundary = next( + port for port in group.boundary_ports + if port.origin == "auto" and port.direction == "output" + ) + connection = next( + connection + for connection in controller.project_state.connections + if connection.src_block().uid == gain.uid + and connection.dst_block().uid == external.uid + ) + controller.remove_connection(connection) + + boundary = controller._find_boundary_port(group, auto_boundary.uid) + assert boundary is not None + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{auto_boundary.uid}:internal" in view.manual_boundary_wires + assert auto_boundary.uid in view.proxy_items + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert not view.manual_boundary_wires + + +def test_reconnect_block_to_group_via_border_port(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks( + [controller.add_block("sources", "constant"), gain], + name="ControlLoop", + ) + controller.add_connection(_first_port(step, "output"), _first_port(gain, "input")) + view.refresh_visual_groups() + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.origin == "auto") + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + + border_port = view.group_items[group.uid].boundary_port_items[boundary.uid] + step_port = view.get_block_item_from_instance(step).get_port_item( + _first_port(step, "output").name + ) + assert controller.try_wire_boundary_endpoints(step_port, border_port) + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + assert len(controller.project_state.connections) == 1 + view.refresh_manual_boundary_wires() + assert not view.manual_boundary_wires + + +def test_reconnect_after_delete_via_boundary_ports(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([controller.add_block("sources", "constant"), gain]) + controller.add_connection(_first_port(external, "output"), _first_port(gain, "input")) + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.origin == "auto") + connection = next( + conn + for conn in controller.project_state.connections + if conn.dst_block().uid == gain.uid and conn.src_block().uid == external.uid + ) + controller.remove_connection(connection) + + controller.add_connection( + _first_port(external, "output"), + _first_port(gain, "input"), + ) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + assert len(controller.project_state.connections) == 1 + + +def test_cross_group_delete_keeps_border_ports_without_dashed_wires(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="GroupA") + group_b = controller.group_blocks([c, d], name="GroupB") + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + boundary_a = next(port for port in group_a.boundary_ports if port.origin == "auto") + boundary_b = next(port for port in group_b.boundary_ports if port.origin == "auto") + + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert not view.manual_boundary_wires + assert boundary_a.uid in view.group_items[group_a.uid].boundary_port_items + assert boundary_b.uid in view.group_items[group_b.uid].boundary_port_items + assert not boundary_a.linked_connection_uid + assert not boundary_b.linked_connection_uid + + +def test_cross_group_reconnect_via_group_boundary_ports(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="GroupA") + group_b = controller.group_blocks([c, d], name="GroupB") + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + + port_a = view.group_items[group_a.uid].boundary_port_items[ + next(port.uid for port in group_a.boundary_ports if port.direction == "output") + ] + port_b = view.group_items[group_b.uid].boundary_port_items[ + next(port.uid for port in group_b.boundary_ports if port.direction == "input") + ] + + assert controller._wire_group_boundaries_together(port_a, port_b) + assert len(controller.project_state.connections) == 1 + restored = controller.project_state.connections[0] + assert restored.src_block().uid == b.uid + assert restored.dst_block().uid == c.uid + + +def test_auto_boundary_internal_dashed_after_cross_group_delete(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + dist = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + plant_in = controller.add_block("operators", "sum") + group_a = controller.group_blocks([dist, gain], name="Disturbance") + group_b = controller.group_blocks([plant_in], name="Plant") + controller.add_connection(_first_port(gain, "output"), _first_port(plant_in, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + out_boundary = next( + port for port in group_a.boundary_ports if port.direction == "output" + ) + controller.remove_connection(connection) + view.refresh_visual_groups() + + view.enter_group(group_a.uid) + view.refresh_manual_boundary_wires() + assert f"{out_boundary.uid}:internal" in view.manual_boundary_wires + assert out_boundary.uid in view.proxy_items + + +def test_parent_group_shows_proxies_for_child_boundaries(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + monitor = controller.add_block("operators", "gain") + inner = controller.group_blocks([c, d], name="Plant") + parent = controller.group_blocks( + [monitor], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + external = controller.add_block("sources", "constant") + controller.add_connection(_first_port(external, "output"), _first_port(c, "input")) + + parent_boundary = next( + port + for port in controller.project_state.get_visual_group(parent.uid).boundary_ports + if port.linked_port_uid.startswith(c.uid) + ) + view.enter_group(parent.uid) + assert parent_boundary.uid in view.proxy_items + assert view.proxy_items[parent_boundary.uid].isVisible() + + view.enter_group(inner.uid) + inner_boundary = next( + port for port in controller.project_state.get_visual_group(inner.uid).boundary_ports + if port.direction == "input" + ) + assert inner_boundary.uid in view.proxy_items + + +def test_parent_with_only_child_groups_shows_proxies(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + regulator_a = controller.add_block("operators", "sum") + regulator_b = controller.add_block("controllers", "state_feedback") + plant_a = controller.add_block("operators", "sum") + plant_b = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([regulator_a, regulator_b], name="Regulator") + plant = controller.group_blocks([plant_a, plant_b], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + step = controller.add_block("sources", "step") + controller.add_connection(_first_port(step, "output"), _first_port(regulator_a, "input")) + + view.enter_group(control_loop.uid) + assert len(view.proxy_items) == len(control_loop.boundary_ports) + for proxy in view.proxy_items.values(): + assert proxy.isVisible() + + +def test_cross_child_group_delete_and_reconnect_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + inner_a = controller.group_blocks([a, b], name="InnerA") + inner_b = controller.group_blocks([c, d], name="InnerB") + parent = controller.group_blocks( + [], + child_group_uids=[inner_a.uid, inner_b.uid], + name="Parent", + ) + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.enter_group(parent.uid) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert not view.manual_boundary_wires + inner_a = controller.project_state.get_visual_group(inner_a.uid) + inner_b = controller.project_state.get_visual_group(inner_b.uid) + out_uid = next(port.uid for port in inner_a.boundary_ports if port.direction == "output") + in_uid = next(port.uid for port in inner_b.boundary_ports if port.direction == "input") + assert out_uid in view.group_items[inner_a.uid].boundary_port_items + assert in_uid in view.group_items[inner_b.uid].boundary_port_items + + port_out = view.group_items[inner_a.uid].boundary_port_items[out_uid] + port_in = view.group_items[inner_b.uid].boundary_port_items[in_uid] + assert controller._wire_group_boundaries_together(port_out, port_in) + assert len(controller.project_state.connections) == 1 + + +def _nested_control_loop(controller): + sum_err = controller.add_block("operators", "sum") + controller_block = controller.add_block("controllers", "state_feedback") + sum_plant = controller.add_block("operators", "sum") + system = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([sum_err, controller_block], name="Regulator") + plant = controller.group_blocks([sum_plant, system], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + return control_loop, regulator, plant, sum_err + + +def test_delete_inside_nested_group_preserves_parent_external(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + control_loop, regulator, _plant, sum_err = _nested_control_loop(controller) + controller.add_connection(_first_port(step, "output"), _port_named(sum_err, "in1")) + view.refresh_visual_groups() + + control_loop = controller.project_state.get_visual_group(control_loop.uid) + regulator = controller.project_state.get_visual_group(regulator.uid) + connection = next(iter(controller.project_state.connections)) + + view.enter_group(regulator.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + assert len(controller.project_state.connections) == 0 + regulator_boundary = next( + port for port in regulator.boundary_ports if port.direction == "input" + ) + assert regulator_boundary.external_port_uid + assert regulator_boundary.linked_port_uid == "" + assert not regulator_boundary.linked_connection_uid + + parent_boundary = next( + port for port in control_loop.boundary_ports if port.direction == "input" + ) + assert parent_boundary.origin == "auto" + assert parent_boundary.external_port_uid == f"{step.uid}:{_first_port(step, 'output').name}" + assert parent_boundary.linked_port_uid == "" + assert not parent_boundary.linked_connection_uid + + while view.current_view_group_uid is not None: + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{parent_boundary.uid}:external" in view.manual_boundary_wires + + +def test_reconnect_inside_nested_group_after_delete(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + control_loop, regulator, _plant, sum_err = _nested_control_loop(controller) + controller.add_connection(_first_port(step, "output"), _port_named(sum_err, "in1")) + view.refresh_visual_groups() + + regulator = controller.project_state.get_visual_group(regulator.uid) + connection = next(iter(controller.project_state.connections)) + boundary = next(port for port in regulator.boundary_ports if port.direction == "input") + + view.enter_group(regulator.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + proxy_port = view.proxy_items[boundary.uid].port_item + sum_port = view.get_block_item_from_instance(sum_err).get_port_item( + _port_named(sum_err, "in1").name + ) + assert controller.try_wire_boundary_endpoints(proxy_port, sum_port) + assert len(controller.project_state.connections) == 1 + restored = controller.project_state.connections[0] + assert restored.src_block().uid == step.uid + assert restored.dst_block().uid == sum_err.uid + + +def test_reconnect_outside_nested_group_after_delete(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + control_loop, regulator, _plant, sum_err = _nested_control_loop(controller) + controller.add_connection(_first_port(step, "output"), _port_named(sum_err, "in1")) + view.refresh_visual_groups() + + control_loop = controller.project_state.get_visual_group(control_loop.uid) + connection = next(iter(controller.project_state.connections)) + parent_boundary = next( + port for port in control_loop.boundary_ports if port.direction == "input" + ) + + view.enter_group(regulator.uid) + controller.remove_connection(connection) + view.pop_view_level() + view.refresh_visual_groups() + + border_port = view.group_items[control_loop.uid].boundary_port_items[parent_boundary.uid] + step_port = view.get_block_item_from_instance(step).get_port_item( + _first_port(step, "output").name + ) + assert controller.try_wire_boundary_endpoints(step_port, border_port) + assert len(controller.project_state.connections) == 0 + + view.enter_group(regulator.uid) + regulator = controller.project_state.get_visual_group(regulator.uid) + boundary = next(port for port in regulator.boundary_ports if port.direction == "input") + proxy_port = view.proxy_items[boundary.uid].port_item + sum_port = view.get_block_item_from_instance(sum_err).get_port_item( + _port_named(sum_err, "in1").name + ) + assert controller.try_wire_boundary_endpoints(proxy_port, sum_port) + assert len(controller.project_state.connections) == 1 + + +def _port_named(block, name: str): + for port in block.ports: + if port.name == name: + return port + raise AssertionError(f"No port named {name} for block {block.name}") + + +def test_group_border_ports_keep_vertical_order_after_rebuild(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + top = controller.add_block("operators", "sum") + middle = controller.add_block("operators", "sum") + bottom = controller.add_block("operators", "sum") + ext_top = controller.add_block("sources", "constant") + ext_middle = controller.add_block("sources", "step") + ext_bottom = controller.add_block("sources", "constant") + controller.add_connection(_first_port(ext_top, "output"), _port_named(top, "in1")) + controller.add_connection(_first_port(ext_middle, "output"), _port_named(middle, "in1")) + controller.add_connection(_first_port(ext_bottom, "output"), _port_named(bottom, "in1")) + group = controller.group_blocks([top, middle, bottom], name="Plant") + view.refresh_visual_groups() + + group_item = view.group_items[group.uid] + positions_before = { + uid: float(item.pos().y()) + for uid, item in group_item.boundary_port_items.items() + } + + inputs = [p for p in group.boundary_ports if p.direction == "input"] + outputs = [p for p in group.boundary_ports if p.direction == "output"] + group.boundary_ports = list(reversed(inputs)) + outputs + controller._rebuild_group_boundary_ports(group) + group_item.sync_boundary_ports() + + for uid, y in positions_before.items(): + assert float(group_item.boundary_port_items[uid].pos().y()) == y + + +def test_cross_group_delete_does_not_spawn_multiple_root_dashed_wires(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="Disturbance") + group_b = controller.group_blocks([c, d], name="ControlLoop") + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert len(view.manual_boundary_wires) == 0 + + +def test_proxy_wires_to_child_group_border_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + external = controller.add_block("sources", "constant") + inner_a = controller.add_block("operators", "sum") + inner_b = controller.add_block("controllers", "state_feedback") + controller.add_connection(_first_port(external, "output"), _port_named(inner_a, "in1")) + inner = controller.group_blocks([inner_a, inner_b], name="Regulator") + monitor = controller.add_block("operators", "gain") + parent = controller.group_blocks( + [monitor], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + view.enter_group(parent.uid) + + boundary = controller.add_manual_boundary_port(parent.uid, "input", QPointF(20.0, 40.0)) + inner_group = controller.project_state.get_visual_group(inner.uid) + child_boundary = next( + port for port in inner_group.boundary_ports if port.direction == "input" + ) + child_port = view.group_items[inner.uid].boundary_port_items[child_boundary.uid] + + assert controller.try_wire_boundary_endpoints( + view.proxy_items[boundary.uid].port_item, + child_port, + ) + + parent = controller.project_state.get_visual_group(parent.uid) + wired = controller._find_boundary_port(parent, boundary.uid) + assert wired.linked_port_uid == f"{inner_a.uid}:{_port_named(inner_a, 'in1').name}" + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + +def _wire_path_end(wire: ManualBoundaryWireItem) -> QPointF: + path = wire.path() + return path.pointAtPercent(1.0) + + +def test_dashed_wire_follows_child_group_move_in_parent_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + external = controller.add_block("sources", "constant") + inner_a = controller.add_block("operators", "sum") + inner_b = controller.add_block("controllers", "state_feedback") + controller.add_connection(_first_port(external, "output"), _port_named(inner_a, "in1")) + inner = controller.group_blocks([inner_a, inner_b], name="Regulator") + monitor = controller.add_block("operators", "gain") + parent = controller.group_blocks( + [monitor], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + view.enter_group(parent.uid) + + boundary = controller.add_manual_boundary_port(parent.uid, "input", QPointF(20.0, 40.0)) + inner_group = controller.project_state.get_visual_group(inner.uid) + child_boundary = next( + port for port in inner_group.boundary_ports if port.direction == "input" + ) + child_port = view.group_items[inner.uid].boundary_port_items[child_boundary.uid] + assert controller.try_wire_boundary_endpoints( + view.proxy_items[boundary.uid].port_item, + child_port, + ) + + view.pop_view_level() + controller.remove_connection(controller.project_state.connections[0]) + view.enter_group(parent.uid) + view.refresh_manual_boundary_wires() + + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + child_item = view.group_items[inner.uid] + anchor_before = child_item.get_boundary_anchor(child_boundary.uid) + + child_item.setPos(child_item.pos() + QPointF(50.0, 30.0)) + qtbot.wait(10) + + anchor_after = child_item.get_boundary_anchor(child_boundary.uid) + wire_end = _wire_path_end(wire) + assert anchor_before != anchor_after + assert abs(wire_end.x() - anchor_after.x()) < 1.0 + assert abs(wire_end.y() - anchor_after.y()) < 1.0 + + +def test_dashed_wire_follows_member_block_move_in_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + controller.remove_connection(controller.project_state.connections[0]) + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + gain_item = view.get_block_item_from_instance(gain) + port_item = gain_item.get_port_item(_first_port(gain, "input").name) + + gain_item.setPos(gain_item.pos() + QPointF(40.0, 25.0)) + qtbot.wait(10) + + wire_end = _wire_path_end(wire) + anchor_after = port_item.connection_anchor() + assert abs(wire_end.x() - anchor_after.x()) < 1.0 + assert abs(wire_end.y() - anchor_after.y()) < 1.0 diff --git a/tests/gui/test_nested_visual_groups.py b/tests/gui/test_nested_visual_groups.py new file mode 100644 index 0000000..6ee2084 --- /dev/null +++ b/tests/gui/test_nested_visual_groups.py @@ -0,0 +1,352 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.main_window import MainWindow + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _select_items(view, *items): + view.diagram_scene.clearSelection() + for item in items: + item.setSelected(True) + + +def test_group_block_with_existing_subgroup(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + + assert inner is not None + parent = controller.group_blocks( + [c], + child_group_uids=[inner.uid], + name="Outer", + ) + + assert parent is not None + assert parent.parent_uid is None + assert inner.parent_uid == parent.uid + assert parent.child_group_uids == [inner.uid] + assert set(parent.members) == {c.uid} + + +def test_nested_group_visibility_at_root_and_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + parent = controller.group_blocks([c], child_group_uids=[inner.uid], name="Outer") + + view.refresh_visual_groups() + assert view.get_block_item_from_instance(a).isVisible() is False + assert view.group_items[inner.uid].isVisible() is False + assert view.group_items[parent.uid].isVisible() is True + + view.enter_group(parent.uid) + assert view.get_block_item_from_instance(c).isVisible() is True + assert view.get_block_item_from_instance(a).isVisible() is False + assert view.group_items[inner.uid].isVisible() is True + + +def test_group_selected_block_and_subgroup_from_context_menu(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + + _select_items( + view, + view.get_block_item_from_instance(c), + view.group_items[inner.uid], + ) + controller.group_selected_blocks() + + outer = next( + group + for group in controller.project_state.visual_groups + if group.uid != inner.uid + ) + assert outer.child_group_uids == [inner.uid] + assert inner.parent_uid == outer.uid + + +def test_ungroup_parent_promotes_child_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + parent = controller.group_blocks([c], child_group_uids=[inner.uid], name="Outer") + + assert controller.ungroup(parent.uid) + promoted = controller.project_state.get_visual_group(inner.uid) + assert promoted is not None + assert promoted.parent_uid is None + assert controller.project_state.get_visual_group(parent.uid) is None + + +def test_create_subgroup_inside_parent_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + parent = controller.group_blocks([a, b, c], name="Parent") + view.enter_group(parent.uid) + + child = controller.group_blocks([a, b], name="Child", parent_uid=parent.uid) + + assert child is not None + assert child.parent_uid == parent.uid + assert child.uid in parent.child_group_uids + assert set(parent.members) == {c.uid} + assert set(child.members) == {a.uid, b.uid} + + +def test_cross_group_connection_visible_at_root(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + controller.group_blocks([a, b], name="GroupA") + controller.group_blocks([c, d], name="GroupB") + + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + assert view.connections[connection].isVisible() is True + + +def test_cross_child_connection_visible_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + inner_a = controller.group_blocks([a, b], name="InnerA") + inner_b = controller.group_blocks([c, d], name="InnerB") + parent = controller.group_blocks( + [], + child_group_uids=[inner_a.uid, inner_b.uid], + name="Parent", + ) + assert parent is not None + + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + conn_item = view.connections[connection] + assert conn_item.isVisible() is False + + view.enter_group(parent.uid) + view.refresh_visual_groups() + assert conn_item.isVisible() is True + + +def test_delete_group_removes_nested_members_and_child_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + sum_err = controller.add_block("operators", "sum") + controller_block = controller.add_block("controllers", "state_feedback") + sum_plant = controller.add_block("operators", "sum") + system = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([sum_err, controller_block], name="Regulator") + plant = controller.group_blocks([sum_plant, system], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + assert control_loop is not None + + member_uids = { + sum_err.uid, + controller_block.uid, + sum_plant.uid, + system.uid, + } + assert controller.delete_group(control_loop.uid) + + assert controller.project_state.get_visual_group(control_loop.uid) is None + assert controller.project_state.get_visual_group(regulator.uid) is None + assert controller.project_state.get_visual_group(plant.uid) is None + for uid in member_uids: + assert controller._find_block_by_uid(uid) is None + + window.undo_manager.stack.undo() + assert controller.project_state.get_visual_group(control_loop.uid) is not None + for uid in member_uids: + assert controller._find_block_by_uid(uid) is not None + + +def _wire_length(points) -> float: + total = 0.0 + for index in range(len(points) - 1): + a = points[index] + b = points[index + 1] + total += abs(a.x() - b.x()) + abs(a.y() - b.y()) + return total + + +def test_manual_route_recomputed_in_nested_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + external = controller.add_block("sources", "constant") + inner_a = controller.add_block("operators", "sum") + inner_b = controller.add_block("controllers", "state_feedback") + controller.add_connection(_first_port(external, "output"), _first_port(inner_a, "input")) + inner = controller.group_blocks([inner_a, inner_b], name="Regulator") + controller.group_blocks( + [controller.add_block("operators", "gain")], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + + connection = next(iter(controller.project_state.connections)) + conn_item = view.connections[connection] + conn_item.update_position() + manual_points = [QPointF(point) for point in conn_item.route.points] + manual_points.insert(2, QPointF(manual_points[1].x() + 300.0, manual_points[1].y() + 250.0)) + conn_item.apply_manual_route(manual_points) + absurd_length = _wire_length(conn_item.route.points) + + view.enter_group(inner.uid) + view.refresh_visual_groups() + conn_item.update_position() + + assert not conn_item.is_manual + assert _wire_length(conn_item.route.points) < absurd_length * 0.5 + + +def test_undo_connection_delete_does_not_duplicate_output_proxy(qtbot, tmp_path): + import shutil + from pathlib import Path + + example_dir = ( + Path(__file__).resolve().parents[2] / "examples/basics/nested_groups/gui" + ) + project_dir = tmp_path / "nested" + shutil.copytree(example_dir, project_dir) + + window = _create_window(qtbot, project_dir) + controller = window.project_controller + view = window.view + + control_loop_uid = "3729168ddf894f62a7b4f1633b4b3014" + system_uid = "3f552e25cb9647bba4ce08cf897de600" + monitor_uid = "5417b603712a41db8d3ef5d0df87b393" + + connection = next( + conn + for conn in controller.project_state.connections + if conn.src_block().uid == system_uid + and conn.src_port.name == "y" + and conn.dst_block().uid == monitor_uid + ) + + view.enter_group(control_loop_uid) + controller.remove_connection(connection) + + control_loop = controller.project_state.get_visual_group(control_loop_uid) + assert control_loop is not None + + window.undo_manager.stack.undo() + + output_boundaries = [ + port for port in control_loop.boundary_ports if port.direction == "output" + ] + assert len(output_boundaries) == 1 + assert output_boundaries[0].linked_connection_uid + assert len(controller.project_state.connections) == 9 + + view.refresh_visual_groups() + visible_outputs = [ + boundary_uid + for boundary_uid, proxy in view.proxy_items.items() + if proxy.isVisible() and proxy.boundary.direction == "output" + ] + assert len(visible_outputs) == 1 + + +def test_manual_route_recomputed_when_entering_control_loop_from_root(qtbot, tmp_path): + import shutil + from pathlib import Path + + example_dir = ( + Path(__file__).resolve().parents[2] / "examples/basics/nested_groups/gui" + ) + project_dir = tmp_path / "nested" + shutil.copytree(example_dir, project_dir) + + window = _create_window(qtbot, project_dir) + controller = window.project_controller + view = window.view + + control_loop_uid = "3729168ddf894f62a7b4f1633b4b3014" + controller_uid = "3643a75d10754f21afb56a8a24844cdb" + sum_plant_uid = "db6d7d946a14478fa5fed05018e0a64a" + + connection = next( + conn + for conn in controller.project_state.connections + if conn.src_block().uid == controller_uid + and conn.src_port.name == "u" + and conn.dst_block().uid == sum_plant_uid + and conn.dst_port.name == "in1" + ) + conn_item = view.connections[connection] + conn_item.update_position() + manual_points = [QPointF(point) for point in conn_item.route.points] + manual_points.insert(2, QPointF(manual_points[1].x() + 350.0, manual_points[1].y() + 200.0)) + conn_item.apply_manual_route(manual_points) + absurd_length = _wire_length(conn_item.route.points) + + view.enter_group(control_loop_uid) + view.refresh_visual_groups() + conn_item.update_position() + + assert not conn_item.is_manual + assert _wire_length(conn_item.route.points) < absurd_length * 0.5 diff --git a/tests/gui/test_visual_group_controller.py b/tests/gui/test_visual_group_controller.py new file mode 100644 index 0000000..3faa1a9 --- /dev/null +++ b/tests/gui/test_visual_group_controller.py @@ -0,0 +1,189 @@ +from pySimBlocks.gui.main_window import MainWindow + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No port with direction={direction} for block {block.name}") + + +def test_group_blocks_creates_visual_group_with_boundaries(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, gain], name="Loop") + + assert group.name == "Loop" + assert set(group.members) == {src.uid, gain.uid} + assert len(group.boundary_ports) == 1 + assert group.boundary_ports[0].direction == "output" + assert group.boundary_ports[0].origin == "auto" + + +def test_ungroup_removes_visual_group(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + assert len(controller.project_state.visual_groups) == 1 + assert controller.ungroup(group.uid) + assert controller.project_state.visual_groups == [] + assert not controller.ungroup("missing") + + +def test_remove_block_updates_group_membership(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + controller.remove_block(a) + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert restored.members == [b.uid] + + controller.remove_block(b) + assert controller.project_state.get_visual_group(group.uid) is None + + +def _port_named(block, name: str): + for port in block.ports: + if port.name == name: + return port + raise AssertionError(f"No port named {name} for block {block.name}") + + +def test_add_block_to_group_moves_membership(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + group = controller.group_blocks([a, b]) + + assert controller.add_block_to_group(group.uid, c) + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert set(restored.members) == {a.uid, b.uid, c.uid} + + +def test_add_block_in_group_view_creates_member(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.enter_group(group.uid) + + added = controller.add_block_in_group_view("operators", "sum", group.uid) + assert added is not None + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert added.uid in restored.members + assert view.get_block_item_from_instance(added).isVisible() + + +def test_remove_block_from_group_keeps_block_visible_at_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + group = controller.group_blocks([a, b, c]) + view.refresh_visual_groups() + + assert controller.remove_block_from_group(group.uid, c.uid) + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert restored.members == [a.uid, b.uid] + assert controller._find_block_by_uid(c.uid) is not None + + view.refresh_visual_groups() + assert view.get_block_item_from_instance(c).isVisible() + + +def test_cannot_add_block_already_owned_by_another_group(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b]) + group_b = controller.group_blocks([c, d]) + + assert not controller.add_block_to_group(group_b.uid, a) + assert controller.project_state.get_visual_group(group_a.uid).members == [a.uid, b.uid] + + +def test_boundary_ports_recomputed_when_connections_change(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + summ = controller.add_block("operators", "sum") + out = controller.add_block("operators", "sum") + + controller.add_connection(_first_port(src, "output"), _port_named(summ, "in1")) + controller.add_connection(_first_port(summ, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, summ], name="Loop") + assert len(group.boundary_ports) == 1 + assert group.boundary_ports[0].direction == "output" + + ext = controller.add_block("sources", "step") + connections_before = len(controller.project_state.connections) + controller.add_connection(_first_port(ext, "output"), _port_named(summ, "in2")) + assert len(controller.project_state.connections) == connections_before + 1 + + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert len(restored.boundary_ports) == 2 + directions = {port.direction for port in restored.boundary_ports} + assert directions == {"input", "output"} + + crossing = next( + connection + for connection in controller.project_state.connections + if connection.src_block().uid == ext.uid + ) + controller.remove_connection(crossing) + + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert len(restored.boundary_ports) == 2 + input_boundary = next( + port for port in restored.boundary_ports if port.direction == "input" + ) + assert input_boundary.origin == "auto" + assert not input_boundary.linked_connection_uid + assert input_boundary.external_port_uid == "" + assert input_boundary.linked_port_uid.endswith(":in2") diff --git a/tests/gui/test_visual_group_persistence.py b/tests/gui/test_visual_group_persistence.py new file mode 100644 index 0000000..073948e --- /dev/null +++ b/tests/gui/test_visual_group_persistence.py @@ -0,0 +1,138 @@ +from pySimBlocks.gui.main_window import MainWindow +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup +from pySimBlocks.gui.services.yaml_tools import build_project_yaml + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def test_visual_group_from_dict_preserves_group_layout_with_member_layouts(): + group = VisualGroup.from_dict( + { + "uid": "grp_1", + "name": "Group", + "members": ["a", "b"], + "layout": {"x": -24.0, "y": -24.0, "width": 168.0, "height": 108.0}, + "member_layouts": { + "a": {"x": 0.0, "y": 0.0, "width": 120.0, "height": 60.0}, + }, + } + ) + assert group.layout["x"] == -24.0 + assert group.layout["width"] == 168.0 + + +def test_build_project_yaml_includes_gui_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + state = window.project_state + + state.visual_groups = [ + VisualGroup( + uid="grp_1", + name="Group 1", + parent_uid=None, + members=["a", "b"], + layout={"x": 10.0, "y": 20.0, "width": 120.0, "height": 70.0}, + boundary_ports=[ + BoundaryPort( + uid="bp_1", + direction="input", + linked_port_uid="p_1", + origin="manual", + linked_connection_uid="", + proxy_layout={"x": 1.0, "y": 2.0}, + ) + ], + child_group_uids=[], + member_layouts={ + "a": {"x": 10.0, "y": 20.0, "width": 120.0, "height": 60.0}, + }, + ) + ] + + raw = build_project_yaml(state, {}) + assert "gui" in raw + assert "groups" in raw["gui"] + assert len(raw["gui"]["groups"]) == 1 + group = raw["gui"]["groups"][0] + assert group["uid"] == "grp_1" + assert group["boundary_ports"][0]["origin"] == "manual" + assert group["member_layouts"]["a"]["x"] == 10.0 + + +def test_loader_restores_visual_groups_from_yaml(qtbot, tmp_path): + project_yaml = tmp_path / "project.yaml" + project_yaml.write_text( + """schema_version: 1 +project: + name: grouped_project +simulation: + dt: 0.01 + T: 1.0 + solver: fixed +diagram: + blocks: [] + connections: [] +gui: + layout: + blocks: {} + groups: + - uid: "grp_1" + name: "Loop" + parent_uid: null + members: ["a", "b"] + layout: + x: 10.0 + y: 20.0 + width: 120.0 + height: 70.0 + boundary_ports: + - uid: "bp_1" + direction: input + linked_port_uid: "port_a" + origin: manual + linked_connection_uid: "" + proxy_layout: {x: 0.0, y: 0.0} + child_group_uids: [] + member_layouts: + a: {x: 5.0, y: 6.0, width: 120.0, height: 60.0} +""" + ) + + window = _create_window(qtbot, tmp_path) + + groups = window.project_state.visual_groups + assert len(groups) == 1 + assert groups[0].uid == "grp_1" + assert groups[0].boundary_ports[0].origin == "manual" + assert groups[0].member_layouts["a"]["x"] == 5.0 + + +def test_loader_keeps_backward_compatibility_without_gui_groups(qtbot, tmp_path): + project_yaml = tmp_path / "project.yaml" + project_yaml.write_text( + """schema_version: 1 +project: + name: no_groups +simulation: + dt: 0.01 + T: 1.0 + solver: fixed +diagram: + blocks: [] + connections: [] +gui: + layout: + blocks: {} +""" + ) + + window = _create_window(qtbot, tmp_path) + + assert window.project_state.visual_groups == [] diff --git a/tests/gui/test_visual_group_view.py b/tests/gui/test_visual_group_view.py new file mode 100644 index 0000000..11b7074 --- /dev/null +++ b/tests/gui/test_visual_group_view.py @@ -0,0 +1,393 @@ +from PySide6.QtCore import QPointF, QRectF, Qt + +from pySimBlocks.gui.main_window import MainWindow + + +def _create_window(qtbot, tmp_path): + window = MainWindow(tmp_path) + window.confirm_discard_or_save = lambda _action_name: True + qtbot.addWidget(window) + window.show() + qtbot.waitUntil(lambda: window.isVisible()) + return window + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No port with direction={direction} for block {block.name}") + + +def test_group_hides_members_and_shows_group_item(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, gain]) + view.refresh_visual_groups() + + assert group is not None + assert len(view.group_items) == 1 + group_item = view.group_items[group.uid] + assert group_item.isVisible() + + src_item = view.get_block_item_from_instance(src) + gain_item = view.get_block_item_from_instance(gain) + out_item = view.get_block_item_from_instance(out) + assert not src_item.isVisible() + assert not gain_item.isVisible() + assert out_item.isVisible() + + +def test_group_captures_member_layouts_and_internal_view_applies_them(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + item_a = view.get_block_item_from_instance(a) + item_b = view.get_block_item_from_instance(b) + item_a.setPos(100.0, 50.0) + item_b.setPos(200.0, 80.0) + + group = controller.group_blocks([a, b]) + assert group is not None + assert group.member_layouts[a.uid]["x"] == 100.0 + assert group.member_layouts[b.uid]["x"] == 200.0 + + item_a.setPos(999.0, 999.0) + view.enter_group(group.uid) + assert item_a.pos().x() == 100.0 + assert item_b.pos().x() == 200.0 + + item_a.setPos(120.0, 60.0) + view.exit_group_view() + assert group.member_layouts[a.uid]["x"] == 120.0 + + +def test_double_click_enters_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + view.enter_group(group.uid) + assert view.current_view_group_uid == group.uid + assert view.get_block_item_from_instance(a).isVisible() + assert view.get_block_item_from_instance(b).isVisible() + assert not view.group_items[group.uid].isVisible() + + +def test_keyboard_shortcut_groups_selection(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + item_a = view.get_block_item_from_instance(a) + item_b = view.get_block_item_from_instance(b) + item_a.setSelected(True) + item_b.setSelected(True) + + view.setFocus() + qtbot.waitUntil(lambda: view.hasFocus()) + qtbot.keyClick(view.viewport(), Qt.Key_G, Qt.ControlModifier | Qt.ShiftModifier) + + assert len(controller.project_state.visual_groups) == 1 + + +def test_keyboard_shortcut_ungroups_selection(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + group_item = view.group_items[group.uid] + view.diagram_scene.clearSelection() + group_item.setSelected(True) + view.setFocus() + qtbot.waitUntil(lambda: view.hasFocus()) + qtbot.keyClick(view.viewport(), Qt.Key_U, Qt.ControlModifier | Qt.ShiftModifier) + + assert controller.project_state.visual_groups == [] + assert view.get_block_item_from_instance(a).isVisible() + assert view.get_block_item_from_instance(b).isVisible() + + +def test_undo_redo_move_resize_group(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + group_item = view.group_items[group.uid] + old_pos = QPointF(group_item.pos()) + old_rect = QRectF(group_item.rect()) + new_pos = QPointF(old_pos.x() + 30.0, old_pos.y() + 15.0) + new_rect = QRectF(0.0, 0.0, old_rect.width() + 40.0, old_rect.height() + 20.0) + + controller.execute_move_resize_group( + group.uid, old_pos, old_rect, new_pos, new_rect + ) + + assert group_item.pos() == new_pos + assert group_item.rect().width() == new_rect.width() + assert group_item.rect().height() == new_rect.height() + assert group.layout["width"] == new_rect.width() + assert group.layout["height"] == new_rect.height() + + stack.undo() + assert group_item.pos() == old_pos + assert group_item.rect().width() == old_rect.width() + assert group_item.rect().height() == old_rect.height() + assert group.layout["width"] == old_rect.width() + assert group.layout["height"] == old_rect.height() + + stack.redo() + assert group_item.pos() == new_pos + assert group_item.rect().width() == new_rect.width() + assert group_item.rect().height() == new_rect.height() + assert group.layout["width"] == new_rect.width() + assert group.layout["height"] == new_rect.height() + + +def test_redo_move_resize_after_redo_group_keeps_same_uid(qtbot, tmp_path): + """Move/resize commands must keep working after group undo then redo.""" + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + group_uid = group.uid + + group_item = view.group_items[group_uid] + old_pos = QPointF(group_item.pos()) + old_rect = QRectF(group_item.rect()) + new_pos = QPointF(old_pos.x() + 30.0, old_pos.y() + 15.0) + new_rect = QRectF(0.0, 0.0, old_rect.width() + 40.0, old_rect.height() + 20.0) + + controller.execute_move_resize_group( + group_uid, old_pos, old_rect, new_pos, new_rect + ) + + stack.undo() # undo move + stack.undo() # undo group + + assert controller.project_state.visual_groups == [] + assert group_uid not in view.group_items + + stack.redo() # redo group + assert len(controller.project_state.visual_groups) == 1 + restored = controller.project_state.visual_groups[0] + assert restored.uid == group_uid + group_item = view.group_items[group_uid] + assert group_item.pos() == old_pos + + stack.redo() # redo move + assert group_item.pos() == new_pos + assert group_item.rect().width() == new_rect.width() + assert restored.layout["width"] == new_rect.width() + + +def test_group_boundary_proxies_assigned_on_creation(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, gain]) + assert len(group.boundary_ports) >= 1 + for boundary in group.boundary_ports: + assert boundary.proxy_uid + assert boundary.proxy_layout + + +def test_proxies_visible_only_in_internal_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + assert len(view.proxy_items) == 0 + + view.enter_group(group.uid) + assert len(view.proxy_items) == len(group.boundary_ports) + for proxy in view.proxy_items.values(): + assert proxy.isVisible() + + view.exit_group_view() + assert len(view.proxy_items) == len(group.boundary_ports) + for proxy in view.proxy_items.values(): + assert not proxy.isVisible() + + +def test_palette_exposes_group_ports_in_internal_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + assert "group_ports" not in window.get_categories() + + view.enter_group(group.uid) + assert "group_ports" in window.get_categories() + assert window.get_blocks("group_ports") == ["In", "Out"] + + +def test_add_manual_boundary_port_creates_proxy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + auto_count = len(group.boundary_ports) + + view.enter_group(group.uid) + boundary = controller.add_manual_boundary_port( + group.uid, "input", QPointF(50.0, 60.0) + ) + + assert boundary is not None + assert boundary.origin == "manual" + assert boundary.direction == "input" + assert boundary.label == "In" + assert len(group.boundary_ports) == auto_count + 1 + assert boundary.uid in view.proxy_items + assert view.proxy_items[boundary.uid].isVisible() + assert boundary.proxy_layout["x"] == 50.0 + + +def test_add_second_manual_in_gets_unique_name(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.enter_group(group.uid) + + first = controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + second = controller.add_manual_boundary_port(group.uid, "input", QPointF(80.0, 50.0)) + + assert first.label == "In" + assert second.label == "In_1" + view.pop_view_level() + view.refresh_visual_groups() + group_item = view.group_items[group.uid] + assert group_item.boundary_port_items[first.uid].label.toPlainText() == "In" + assert group_item.boundary_port_items[second.uid].label.toPlainText() == "In_1" + + +def test_undo_removes_manual_ports_before_ungrouping(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + view.enter_group(group.uid) + controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + assert any(port.origin == "manual" for port in group.boundary_ports) + + stack.undo() + assert not any(port.origin == "manual" for port in group.boundary_ports) + assert controller.project_state.get_visual_group(group.uid) is not None + + stack.undo() + assert controller.project_state.get_visual_group(group.uid) is None + + +def test_undo_connection_route_edit(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + src = controller.add_block("sources", "constant") + dst = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(dst, "input")) + + conn = controller.project_state.connections[0] + conn_item = view.connections[conn] + conn_item.update_position() + old_points = [QPointF(point) for point in conn_item.route.points] + new_points = [QPointF(point) for point in old_points] + new_points[2] = QPointF(new_points[2].x() + 30.0, new_points[2].y()) + conn_item.apply_manual_route(new_points) + + controller.execute_edit_connection_route(conn, old_points, new_points) + assert conn_item.route.points[2].x() == new_points[2].x() + + stack.undo() + assert conn_item.route.points[2].x() == old_points[2].x() + + stack.redo() + assert conn_item.route.points[2].x() == new_points[2].x() + + +def test_manual_route_preserved_when_block_moves(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + dst = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(dst, "input")) + + conn = controller.project_state.connections[0] + conn_item = view.connections[conn] + conn_item.update_position() + manual_points = [QPointF(point) for point in conn_item.route.points] + manual_points[2] = QPointF(manual_points[2].x() + 40.0, manual_points[2].y()) + conn_item.apply_manual_route(manual_points) + + bend_x = conn_item.route.points[2].x() + dst_item = view.get_block_item_from_instance(dst) + dst_item.setPos(dst_item.pos() + QPointF(50.0, 20.0)) + qtbot.wait(10) + + assert conn_item.is_manual + assert conn_item.route.points[2].x() == bend_x