From 16d9cc76511f098dd3cd80cfcee9da3f96cbfc5e Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Tue, 18 Aug 2026 15:44:24 +0200 Subject: [PATCH] More fixes and extended lib --- examples/BondGraphs.beb | Bin 0 -> 2865 bytes lib/bondgraph.beb | Bin 2715 -> 3069 bytes lib/signal.beb | Bin 2170 -> 3595 bytes lib/signal_sources.beb | Bin 4499 -> 4528 bytes src/bedit_gui/commands/component_command.py | 41 +- .../commands/port_metadata_command.py | 19 + .../controllers/clipboard_controller.py | 8 +- .../controllers/document_tree_controller.py | 29 +- .../controllers/library_controller.py | 22 +- src/bedit_gui/documents/document.py | 73 ++- src/bedit_gui/models.py | 25 + src/bedit_gui/services/component_clipboard.py | 31 +- src/bedit_gui/services/document_files.py | 6 +- src/bedit_gui/ui/forms/port_editor_widget.ui | 14 + .../ui/forms/port_editor_widget_ui.py | 268 ++++++++++ .../views/dialogs/interface_editor_dialog.py | 7 +- src/bedit_gui/views/equation_editor_widget.py | 27 +- src/bedit_gui/views/graph_editor_widget.py | 76 ++- src/bedit_gui/views/port_editor_widget.py | 24 +- untitled.bedit.json | 488 ++++++++++++++++-- 20 files changed, 1057 insertions(+), 101 deletions(-) create mode 100644 examples/BondGraphs.beb create mode 100644 src/bedit_gui/commands/port_metadata_command.py create mode 100644 src/bedit_gui/ui/forms/port_editor_widget_ui.py diff --git a/examples/BondGraphs.beb b/examples/BondGraphs.beb new file mode 100644 index 0000000000000000000000000000000000000000..230332f25e7a5bf808c1ab5bb94e2b75674154cb GIT binary patch literal 2865 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 literal 0 HcmV?d00001 diff --git a/lib/bondgraph.beb b/lib/bondgraph.beb index c5a113782b47c17996d53c7c246fbf00bb274ae5..c11be354af306708a55a97c8d65f12af8d0ada94 100644 GIT binary patch delta 3061 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 D?tl{& literal 2715 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 V^40HN{>y((eeloU{|~TR^U572M!^68 diff --git a/lib/signal.beb b/lib/signal.beb index a9971a5266465410b9b1c204163a9cbf77b1400a..6a8eb5960cd15a48b8c4b229499cdd8cdde3c5b3 100644 GIT binary patch literal 3595 zcmV+m4)pOtMM3}o0004aoaI|fk6qbSwh`7-3i9!JZ0~9f9Ay8)k z;oIldwXf^ZoTgn}6e+*%Irp4>)?VM*-}=^G$6tF?M&nz4di{1DPukJ&;7VXjiXw7rlOj=O z+|C6fK}1Vz)P-iLy$bQG-Tk{ad^^0~4ySose9W)yZH~s%$?N-EM6!e- zOwOdpESQUm@uI(ezg~TPy1kk2PC7ffG2hs}pW1PbQ%vXXXg1xPP2avX8%*2HK^xoY z_PcLkzTxBe2eaIbxjW8&aPNyVKb+#OcXnsPcKQx{ zlU<0;7NVG=MCR%nCJgb~YA1bSBAjU1?1^Q2?eLiz_F&VG@d!9_^4k6-Zs8(p1+mg` zxR1d|M>S!I%92WPjORa^+jMiU8~F}@o*sV2A$tx#@+tcswYN40`BolIeLeo_Up{@@ z4SFwQgnIOL%5eaD>*vWgzTIN9)(@^PzMg#J{^O6|ytmz4X>L9@Ti?HARAQWKMqHG- z##JFs1x64ILJ7E^mOPKE`+hpjLky9R3ohq0*Ewq7-AnuhPccFp^j-)xZ_^hvsjf&!T;q`3&g*!fjF7nTCE|l){yRV=km20 zn^Mt;U&ICpIxie_&J`ufI;(<{CS*B}%?Bxu+uIoQ_VLUoMRM9$5)F7$Mg``h7KF-T zbkrkqEk7IPV@VfXxF<#lBz&2xW}uj{aL8kzl`~JPU)#TAsep%kA~_}q8;~?OLSPXU zoeT}hXqOH#ANxei_ZOc7hBpvGqj6KE5;BDLIVX_`rNtn#0s20N!++Hys5viWdXY}Uwb&NBf9=Mi% zBG>Le_ql~7@a-&5CQUb3o#ouNX7$S4ZWvjRfK>q0HX|q{6_HpSCH@K`8-{GH`~mXU z=H~6fJoZWK`|P#$R&FBLVTy|;&FZxmX)^|8AlB1*bL|Rk+DX$U4BI?e^>Zl>i&^kR zNfU{#>QvGkh*jPZraUw>_r|1!TeqI2Tbq;J+Bx%=|Mwiavw!9Ba%_HmG#X?-Y+lXd z(QjjEfIoehtlnH}KHGeLlrk2-fBvO~_nyz2llAYuTz$2*+RgUL!K~NTk23S0f2W;? zdKdOrt~9)vZsg(HEAaE`)*2o~*T0tslYHnh(Ovd%WeZmv41e^x7q2w3yYAa7IQ`iC zdgk6rck66@Z>8S#Xl13Qg@dSHzqgWyscFmln+N|s$sarWYmZGkJZM9kI#{zhBU@{i zudVN|ZhzEOH!ojXd#<~-DsGvwaUf)*1|%MVI>HyE=omxry5?woP{}=LA2Zc)iAQ=? zqT+}~4oxMK@&rq}5Tl_bjV;oKa5jlY6ogo|j%X!ot*f!9kyZ(JI%geq;_Sqf_~|4b zh)Us@%F-#q7exZdh^ddiq*DHV6_*Yu-Y;g9i^aeK72gP zyjqV5qMZdqs*KQ@Ok3hON-G{CQ!WM#wKC_9dvM+cTzBkFkkpJ@S{yOnqv)5lLJQgv zAEM((y;8(8lB|hCf)!?o&>F&pV`T;jQO1oHwqRvSp3jy!POo11UJZuFY4-EL?|I<2 zI_r`~w#5mfiSPx5T$Ba%1;{GfAZ(zSmGBhsadfnh2Jj_)U(r~S) zkrGAbAus`A`zZn*_|1W*5>%&4CeG19WZl1UJ}02>EtM=nE*`A-`*W)Q;&^%-@?1p2 ziZUeAy1{Fz@Jvw=E;IxX_rfrGM#v|pm_Y795`y}%Abh7H7JsNJ0){DdqGjhD`M0~h zDL{5W?~zd>EF;uY@UmfmU09Uws&2-kxJ=?HdPh}u9M&pIZVFUnvJ@48RVXcNFhodC zX}1-rPt2aDi?uZ=J7olkRv?-a_$TDd322{&baHNSZ=yNc5@NLM;PoXu-nGFzK}z_-Iuclj!=qHBk* zLX<A)$3?m3DmaQ6x@(xHrwo!H4cUEn?HDQC4H zm>g;=r$repmWez-?_cj)Ia8aA@20uC2Y51$- z_Wk}(_8s@x(FjB!Ap$QSJy|EXwtf%3@ySp9>kr$AXV;JQo7WG&!Le7rp7eYT z?9JR2+9=d=E`lI|BcoWvJXCuoOctd>N{%!6Cwl|GoyQk;>&)-hNbtiOgM4RmG}(C{ zKYL$l({$s#tG|Dt`?qvpdaO)A>aH;r+i~OymoPCh~=My5qe=Mj(9-(z|!tsRwlL?$+3P z@9OW&@9hSD<98o*D+=ScT?o9^?;V$&4?pbp-opaOuYkd*x4LkSs>x%*k>N&}vA~6< z;IANjVCM%&8-+uCTU6 zv9M6JB>XW{@B@>6=8#D%|F|$n9WvfY4N~ zwu*(0!n$*`>@D0-Bg_ebH5M@?r*$NlA@Wyp&Zm6Rvj5wETFm2>FF!qx-}@Mg?4_ke zwwo@e=`qW!$Lkb3KlkH)Z#V4x>yP_2+?Fs8Jn-lhM0{cx*J+1XVwNx;q3c}9(dF}l zIp*e*ktniI+hVO*O`H*lq>x24i6z7J37CKI{`VL2__agxxOTNa<}WW{e&?V6_^qRm z_i1EykoV~;c98e!lPp1w#2*bP34EbygeV6Cgd?a>lMxxhAd2$?xiD5lQLr#9ucQSB zxth04$_>d zLYP7p;+Zll>G{E&`r;f_mgItkBTa@v=n6a($=Vcnq?nU1|GOuczw)dwkE6*^%=>ho zI?VetgLZ!LgMQtSCCs@>ibV^NSy+A1tCli#M*;~%ThEeYHk}{LnM;<{|3~1X6SZ?v zdj(k%o@e-K&G%<&xj$+=Y@zi17rw_Dq;e&pSk!5br8OWk&L&Z}smkAt$!=$a& zLI%TlI6s&NgG{NCsXI5dXOdANnJSBIDH==(&`&JEpAqK2v4r_lZAuJ92myP7*Is`pK#$LSdAkFs7&?Q5#5men1Ds z858Q3GPEu92_&pH9JL6EG4IognP>DKospfN{71i5!E&(% zt?m^{c_?0_ebi?JReT`@f&yw#>XOX)!JI3VnTV34Sm{tl1Uv*WanU8Ji_4ClxG#4a zm=8ubj$+=Y3)5lVrzx^?@q_PnPgv~4f5aaAlmBq>Kko{?_MJEy4(DB!>wY*K&ASV4 Rkbb*QC*A(5{|&}$;+MkW4`l!V literal 2170 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 O{Xf0;H~$6Zq-DVeDV_)b literal 4499 zcmV;E5p3>4MM3}o0004aob6jnj9kZcr6MMx0@)xD8qvZC8VC*WgQDyI2}MO)FBBM% z0%SL+FQ<{7?n!qK6=r2iN)#Cg;Lyk-yT~FL4rNm^lmG#;h~PywM1UZ>*%(@Qp@jud zA_Y+Z7diD__fQ(nizj#-Q#9NIH2U+p>b$CZ&poHA=C7}p$#l)nSI+0@Y&aP|_QPS? zIu^Az*&9b<@r+0v3$cy~LRpR`1x=Ngm&SfAUw>hEZtO>c*<@oH^X$@eGMT@I-v?!~ zF_EyyB9SR^;wbmTT0=DxOysCuZ5p^V^=s>!!|^;%i;wxcm)9rL`Ruz}$5Kg(Dn$v^ zIuThSCHOuQp@oT#I!b+ceY0M@GQY5%ugn@CO`C6A*i6G|j&t14mC43@ePjNkwT;ny zxIP-j;rzl+-@*+~hkv;;8~#n!P!BJbZ}}g zAFkz->CDzKpO}<|C&HDsh@rwsV1^M9dEhRIP;_i#=yF6e^WB0-I%kzvl0=VRq76xW zNtQU395a?VFCTUg0VP9PAx8pHB_fEpR3um}h-BOb%S$p^EQ09#mwYa-)VVy5*%K*? z5~L?tXQZzTj93$JW6m(+v{KsH1q00Hc~=JTRHwweA~Is$q{*7t020Z{>!KCS)SHJq zd9Q38%RKVzaC^mPglWLMS#2|^%;3GTl3Mj(CvWSR;V$r;3@J<*aAQWFC4dX4DU6q3 zV_q=88!tYnKB;AirVQ4R41IcMiS@w}BRpfyWR`>6g>~Nea>nV_sT!!m($LM|*|UQe z2lLfD9-Ke5Afh~)X|PT^X(Wp`TQhhM5{mjyq~Y0Mv@%oC)bB3oAf(1XvXve5)BnzB`qXTf@9__NX>DR z2#7Qo)O;h;LO5QDlde=#R>!~#BRPPtB;Ju=D3k-*6~Eh*D%$PCcCGa!>AV&HIl%2 zm1IFZ8B!VgIHKAWQKVex>-7y(!`(8imb8?UB(NlCLmErGw~7=Sywci9!{kHGw7-Fs z^vaqdKn2`ERU3FEcr;Fo^wl#7i*VnsOv^TLX_6*BWJEiwPFUbovY91egraF7)h@p@ zO!L*Y-%F}r)V=fVI%9*T#ldhq_y(q1Q!l7Aq?TD&`l>%t^stVcW3G+LY`03&PrspC z%Bf+FK{leMN+mP#DI(jM%rOOSc(99Z*Yk8Zd0;Nt2&S_a&}h=5k_b$J%hmdU1W;m< zP&~99|MU3sJ^zrf&vpDJg8u)}@76c|WMeJlDbjM#HaZ&|e})d$vLDYd5v68yJ^=z| zLTe9Uh0#Y?SP_VWpCwxGD19=qB)hZWP6}ae6wx2vfKhpy?Xn5uwc`~$@a1{_ek|0w!}--1}t5{@%3pw z-dua)6g#oF9zVa=RCNvi_GF9b?BL$zxQLy3=gDRFXPYL`(>M)k4`;Ui@eAyI99E>c zi@`IWYkV>tEMjwT_UwSx_s5qXJd5`pt+ibP>*9+OCt4>%TMM(aIL|Dn1)VL%zrAjo z@!IQY9kg7mv>etFHrpdeRE zm8S?84m%dDrZ((Lbx2gIP|zn69{lTRA;D?5aixl6)H_|`L#z(UDJ@YQQl_ZYzPizz zC81C;PBQr86n|449zu0cUDct|eP@06_W>RqCF0%sU3Zmu{Z~^Z?wNd|N(w&= zHDpqfCHwDP9{CG-df-xx!_8VB{P^4`zqvk{UHor6T9&-=+@Bq29#>ZL@Z9Qr`~Bu< zIBpZ;@@z61rt9td)nWYeah}a?Ja@Fc?#FR59p~xF)TiMFJ|46mUu!OplhI_l`G*t_ zQ%bp3hNIE)!yM{gDO<-%D$e_238QuAfz=g51vP414pgG9P~%=}@8)20HX;{URq5w zl#x)ZY706t5^}K~b^a?CYW3EVH0n>r~ZnnmEZV<_&RMl5t#=eGXq$ z$;^aMlNgjlQA|B_H7U@Kuw_98%Dck%s||$5zuGH&kwM98Q79)oMJU;Xp>Q0pl&}%> zm?fun0pYiIKzMus;Z{!w6eF&zE|Ad32B`xS2*s19Fg6iZr}*VW{LW4Yx7vy52jTuC z-pkH*lf--3cU&g%&kh{Hc;_UUb)GOTFy4X{;&p(l425YYtua(QI%(`93#P6t$E>P7 z4hE!T3w#i)vCswTV!zXvXUzrGln9JA(pZ9OO+X@nYC(xbLCo%V8pjfY} zUU-sF#sC4DAxP>7$0KI#pdY#@{Z2a(cl(ATH=IJ2IZQ-Vr#fiFf^h^NQCwpk52*vf z-8LNeKx6+mgj%`4N(PhfQWeOoni`ZAPEBU2P>@Ckgad6-9(;9PaN%)#A5WJV6Z&T@wTiyaUSeWii$ z)Zv~GlunvQTF`qvuK{}v-vyQqkmq1-nRMu4$EOZA5Dxc+kg{`Bb4gMN2uC4ny-fu; zGWfxJAv?qIa0}sR--!s)mY@aHrmlxcgeZ0Wx>6I$gM`*p-giJa+D^pNeZ!HNVy#3* zL<~9ziPpWg0=21gR7~s0SXV4O-G<}W`z7W5I%_Xyt}cFdXK%+&extK~&2<)!#v0~K z-IKNTUMwG255OuP|8j(qkQMsam&7!Nk-)_tMoxXeu zSqsI@1(Y@wRbjxv%F0Q|5G^^U+;!@#mQU~Ytt!4q3Wsb+5|!n+MpQ6CK@-L@D`5d> zS1jCZtIC1CwN;BqnXOv4pukEf3KA@GLvv+4z(meFbyizj5B42}XjoDi1K?df`J5tR zp`Ov?RBEqe=1g=Y<-v9o4)u)%#WI87*Cc9Gb}0o$LFqygM5ti!fhu>IpF?ddJk>WN z3KObW&4|dZo^ub3_#mM+6)T=X>e&>!bXJ=Yhx_t^tOi3s;Mr7pAyBYmfD2_@D+^o1 zxa^X8hg)7a+Ls+8kD8WPPsu^$!F5GM1>A5-hP#p#YD5==qb)l=-M2!kjzK%i0vf8ex zOPLDuZ<7hp)iuROXi-|R|J*CG*`O&tXd*P1}+iV9PVl-la$3P#7G^v8V{r7ff2QU#Bg z6P$`sn25TlRD{X2xFWSv9T{}1g>dV3Uml@IVX7R-qK<-K_5TKotMlT7r$q>}w2V6! zrEj&1CS&KM~Bdhanf_25GcPaE38xz zRSqD!2)&iZpY&Gf5E3cNx)77!<+b%Y3N8w1{ zQQ$6Up&1C>@Mgc#)~%{YC|8iEF;OeJ*z`y{3P<{mLXntmI7SldQm{mVqO5U6Xac_k zFGZoIgH4aLqwoj)Mq!sDM|(I24K}@gZtB-pyARe~X}+%w2F*gY*&x?bQ@ZQf4nv`# z6pJZ1m6CsT!MfjhH%`{pC*wTcagcw?sEc=QzH{%_ zBOmf;_MNxlWIS$9W~1@haBXAMo()|Be`P1|pLkq-KCj%nwn}@WlM@1rS;a61b)e1# zLY+X$8mUqH-n#dLig+(Sd3|#m@7{)~IYoQzh3$^6HOC%T>M6(@i&;K!HEpzqTyH#g zto`KejahE4Nh|fg*JnR{d)%DtU73{jga7)yzul}annSlY{`lMB`TzN7^X`?kJYSup l%gN7Xqqm#qmHA|pr+yssjc4Ea&rkp9UqAf2{{<15MlI9*k?H^d diff --git a/src/bedit_gui/commands/component_command.py b/src/bedit_gui/commands/component_command.py index 915fd4b..ba14278 100644 --- a/src/bedit_gui/commands/component_command.py +++ b/src/bedit_gui/commands/component_command.py @@ -1,7 +1,9 @@ +from copy import deepcopy + from PySide6.QtGui import QUndoCommand -from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface -from bedit_gui.models import Icon +from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface, PortID +from bedit_gui.models import Icon, PortMetadata class AddEmptyGraphComponent(QUndoCommand): @@ -62,10 +64,16 @@ class DeleteComponent(QUndoCommand): if connection.source in port_ids or connection.target in port_ids: self.connections.append((index, connection_id, connection)) self.icons = {} + self.port_metadata = {} for component_id in self._component_ids(self.component_id, component): icon = document.stored_component_icon(component_id) if icon is not None: self.icons[component_id] = icon + database = document._port_metadata_database(False) + if database is not None: + for port_id in self._port_ids(component): + if port_id in database.ports: + self.port_metadata[port_id] = deepcopy(database.ports[port_id]) def redo(self) -> None: if self.parent_graph is not None: @@ -75,6 +83,11 @@ class DeleteComponent(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id in self.icons: self.document._set_component_icon(component_id, None) + if self.port_metadata: + database = self.document.port_metadata_database() + for port_id in self.port_metadata: + database.ports.pop(port_id, None) + self.document._set_port_metadata_database(database) def undo(self) -> None: self._restore_item(self.components, self.component_id, self.component, self.component_index) @@ -84,6 +97,10 @@ class DeleteComponent(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id, icon in self.icons.items(): self.document._set_component_icon(component_id, icon) + if self.port_metadata: + database = self.document.port_metadata_database() + database.ports.update(deepcopy(self.port_metadata)) + self.document._set_port_metadata_database(database) @classmethod def _find_component(cls, components: dict[ComponentID, Component], target: Component, parent_graph: Graph | None = None) -> tuple[dict[ComponentID, Component], ComponentID, Graph | None]: @@ -105,6 +122,14 @@ class DeleteComponent(QUndoCommand): component_ids.extend(cls._component_ids(child_id, child)) return component_ids + @classmethod + def _port_ids(cls, component: Component) -> list[PortID]: + port_ids = list(component.interface.ports) + if isinstance(component.implementation, GraphImplementation): + for child in component.implementation.graph.components.values(): + port_ids.extend(cls._port_ids(child)) + return port_ids + @staticmethod def _restore_item(items: dict, item_id: object, item: object, index: int) -> None: values = list(items.items()) @@ -114,7 +139,7 @@ class DeleteComponent(QUndoCommand): class PasteComponents(QUndoCommand): - def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None) -> None: + def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None: super().__init__("Paste components") self.document = document self.target = target @@ -122,6 +147,7 @@ class PasteComponents(QUndoCommand): self.icons = icons self.graph_id = graph_id self.positions = positions or {} + self.port_metadata = deepcopy(port_metadata or {}) def redo(self) -> None: self.target.update(self.components) @@ -131,6 +157,10 @@ class PasteComponents(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id, icon in self.icons.items(): self.document._set_component_icon(component_id, icon) + if self.port_metadata: + database = self.document.port_metadata_database() + database.ports.update(deepcopy(self.port_metadata)) + self.document._set_port_metadata_database(database) def undo(self) -> None: for component_id in self.components: @@ -141,3 +171,8 @@ class PasteComponents(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id in self.icons: self.document._set_component_icon(component_id, None) + if self.port_metadata: + database = self.document.port_metadata_database() + for port_id in self.port_metadata: + database.ports.pop(port_id, None) + self.document._set_port_metadata_database(database) diff --git a/src/bedit_gui/commands/port_metadata_command.py b/src/bedit_gui/commands/port_metadata_command.py new file mode 100644 index 0000000..78fdd01 --- /dev/null +++ b/src/bedit_gui/commands/port_metadata_command.py @@ -0,0 +1,19 @@ +from copy import deepcopy + +from PySide6.QtGui import QUndoCommand + +from bedit_gui.models import PortMetadataDatabase + + +class ChangePortMetadataDatabaseCommand(QUndoCommand): + def __init__(self, document: object, database: PortMetadataDatabase) -> None: + super().__init__("Change port metadata") + self.document = document + self.old_database = document.stored_port_metadata_database() + self.new_database = deepcopy(database) + + def redo(self) -> None: + self.document._set_port_metadata_database(self.new_database) + + def undo(self) -> None: + self.document._set_port_metadata_database(self.old_database) diff --git a/src/bedit_gui/controllers/clipboard_controller.py b/src/bedit_gui/controllers/clipboard_controller.py index 473d102..ced6127 100644 --- a/src/bedit_gui/controllers/clipboard_controller.py +++ b/src/bedit_gui/controllers/clipboard_controller.py @@ -133,11 +133,11 @@ class DocumentTreeClipboardHandler(ClipboardHandler): if target is None or payload is None: return try: - components, icons = import_components(payload) + components, icons, port_metadata = import_components(payload) except (TypeError, ValueError) as exc: QMessageBox.critical(self.tree, "Could not paste components", str(exc)) return - self.document.paste_components(target, components, icons) + self.document.paste_components(target, components, icons, port_metadata) def delete(self) -> None: self.document.delete_components(self._selected_components()) @@ -223,14 +223,14 @@ class GraphEditorClipboardHandler(ClipboardHandler): def _paste_payload(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None: try: - components, icons = import_components(payload) + components, icons, port_metadata = import_components(payload) except (TypeError, ValueError) as exc: QMessageBox.critical(self.editor, "Could not paste components", str(exc)) return x, y = position spacing = self.editor.snap_to_grid_size * 4 positions = {component_id: (x + index * spacing, y + index * spacing) for index, component_id in enumerate(components)} - self.document.paste_graph_components(graph_component, components, icons, positions) + self.document.paste_graph_components(graph_component, components, icons, positions, port_metadata) def delete(self) -> None: components = self._selected_components() diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index f8dbd78..5205178 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -9,7 +9,7 @@ from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument from bedit_gui.documents import Document -from bedit_gui.models import Graph, GraphComponentLabel, Icon +from bedit_gui.models import Graph, GraphComponentLabel, Icon, PortMetadata from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog from bedit_gui.views.icon_editor_window import IconEditorWindow @@ -22,6 +22,7 @@ ICON_SIZE = QSize(16, 16) class InterfaceEditorLike(Protocol): def exec(self) -> int: ... def ports(self) -> dict[PortID, Port]: ... + def port_metadata(self) -> dict[PortID, PortMetadata]: ... class ParamEditorLike(Protocol): def exec(self) -> int: ... @@ -29,7 +30,7 @@ class ParamEditorLike(Protocol): InterfaceEditorFactory = Callable[ - [dict[PortID, Port], MainWindow], + [dict[PortID, Port], dict[PortID, PortMetadata], MainWindow], InterfaceEditorLike, ] @@ -60,15 +61,17 @@ class DocumentTreeController(QObject): window.ui.documentTree.setModel(self.model) window.ui.documentTree.selectionModel().selectionChanged.connect(self._selection_changed) window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text) + window.equation_editor.port_metadata_change_requested.connect(self._change_equation_port_metadata) document.model_changed.connect(self._on_document_changed) document.icon_changed.connect(self._on_icon_changed) + document.port_metadata_database_changed.connect(self._on_port_metadata_database_changed) document.graph_component_position_changed.connect(self._on_graph_component_position_changed) document.graph_component_label_changed.connect(self._on_graph_component_label_changed) document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed) document.equation_text_changed.connect(self._on_equation_text_changed) self.model.rename_document_requested.connect(self.document.rename) self.model.rename_component_requested.connect(self.document.rename_component) - window.graph_editor.component_move_requested.connect(self.document.move_graph_component) + window.graph_editor.component_moves_requested.connect(self.document.move_graph_components) window.graph_editor.component_label_move_requested.connect(self.document.move_graph_component_label) window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu) window.graph_editor.component_open_requested.connect(self._open_graph_component) @@ -125,7 +128,8 @@ class DocumentTreeController(QObject): def _show_component(self, component: Component | None) -> None: if component is not None and isinstance(component.implementation, EquationImplementation): - self.window.equation_editor.set_component(component) + port_metadata = {port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports} + self.window.equation_editor.set_component(component, port_metadata) self.window.equation_editor.show() else: self.window.equation_editor.set_component(None) @@ -133,7 +137,8 @@ class DocumentTreeController(QObject): if component is not None and isinstance(component.implementation, GraphImplementation): graph = self.document.graph_database().graphs.get(self.document.component_id(component), Graph()) icons = {component_id: self.document.component_icon(component_id) for component_id in component.implementation.graph.components} - self.window.graph_editor.set_component(component, graph, icons) + port_metadata = {port_id: self.document.port_metadata(port_id) for child in component.implementation.graph.components.values() for port_id in child.interface.ports} + self.window.graph_editor.set_component(component, graph, icons, port_metadata) self.window.graph_editor.show() else: self.window.graph_editor.set_component(None) @@ -152,6 +157,17 @@ class DocumentTreeController(QObject): if graph_component is not None and isinstance(graph_component.implementation, GraphImplementation) and component_id in graph_component.implementation.graph.components: self._show_component(graph_component) + def _on_port_metadata_database_changed(self, _database: object) -> None: + graph_component = self.window.graph_editor.component() + if graph_component is not None: + self._show_component(graph_component) + equation_component = self.window.equation_editor.component() + if equation_component is not None: + self.window.equation_editor.refresh_port_metadata({port_id: self.document.port_metadata(port_id) for port_id in equation_component.interface.ports}) + + def _change_equation_port_metadata(self, component: Component, port_metadata: dict[PortID, PortMetadata]) -> None: + self.document.update_component_ports(component, component.interface.ports, port_metadata) + def _on_graph_component_position_changed(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None: graph_component = self.window.graph_editor.component() if graph_component is not None and self.document.component_id(graph_component) == graph_id: @@ -242,10 +258,11 @@ class DocumentTreeController(QObject): def _edit_interface(self, component: Component) -> None: dialog = self.interface_editor_factory( component.interface.ports, + {port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports}, self.window, ) if dialog.exec() == QDialog.DialogCode.Accepted: - self.document.update_component_ports(component, dialog.ports()) + self.document.update_component_ports(component, dialog.ports(), dialog.port_metadata()) def _edit_params(self, component: Component) -> None: dialog = self.param_editor_factory( diff --git a/src/bedit_gui/controllers/library_controller.py b/src/bedit_gui/controllers/library_controller.py index 026664d..821dfe0 100644 --- a/src/bedit_gui/controllers/library_controller.py +++ b/src/bedit_gui/controllers/library_controller.py @@ -1,8 +1,8 @@ from PySide6.QtCore import QObject, QSize, Qt from PySide6.QtWidgets import QAbstractItemView, QHeaderView -from bedit_core.models import Component, ComponentID, GraphImplementation -from bedit_gui.models import Icon, IconDatabase +from bedit_core.models import Component, ComponentID, GraphImplementation, PortID +from bedit_gui.models import Icon, IconDatabase, PortMetadata, PortMetadataDatabase from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.services.component_clipboard import export_component_data from bedit_gui.services.libraries import load_library_documents @@ -18,7 +18,7 @@ class LibraryController(QObject): super().__init__(window) self.window = window self.settings = settings - self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon]]] = {} + self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon], dict[PortID, PortMetadata]]] = {} self.model = LibraryTreeModel(self._component_payload) tree = window.ui.libraryTree @@ -44,7 +44,9 @@ class LibraryController(QObject): for library in libraries: database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None icons = database.icons if isinstance(database, IconDatabase) else {} - self._collect_component_sources(library.document.root, icons) + metadata_database = library.document.metadata.get("port_metadata_database") if library.document.metadata is not None else None + port_metadata = metadata_database.ports if isinstance(metadata_database, PortMetadataDatabase) else {} + self._collect_component_sources(library.document.root, icons, port_metadata) self.model.set_documents([library.document for library in libraries]) for library in libraries: database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None @@ -55,20 +57,22 @@ class LibraryController(QObject): def _component_payload(self, components: list[Component]) -> dict: roots = {} icons = {} + port_metadata = {} for component in components: source = self._component_sources.get(id(component)) if source is None: continue - component_id, source_icons = source + component_id, source_icons, source_port_metadata = source roots[component_id] = component icons.update(source_icons) - return export_component_data(roots, icons) + port_metadata.update(source_port_metadata) + return export_component_data(roots, icons, port_metadata) - def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: + def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata]) -> None: for component_id, component in components.items(): - self._component_sources[id(component)] = (component_id, icons) + self._component_sources[id(component)] = (component_id, icons, port_metadata) if isinstance(component.implementation, GraphImplementation): - self._collect_component_sources(component.implementation.graph.components, icons) + self._collect_component_sources(component.implementation.graph.components, icons, port_metadata) def _set_component_icons(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: for component_id, component in components.items(): diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index c823b7f..0f18c58 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -17,12 +17,13 @@ from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnec from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand from bedit_gui.commands.graph_label_command import ChangeGraphComponentLabelCommand from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand +from bedit_gui.commands.port_metadata_command import ChangePortMetadataDatabaseCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents -from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase +from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, PortMetadata, PortMetadataDatabase, Simulation, SimulationDatabase from bedit_gui.services import document_files @@ -35,6 +36,7 @@ class Document(QObject): icon_changed = Signal(object, object) equation_text_changed = Signal(object, str) simulation_database_changed = Signal(object) + port_metadata_database_changed = Signal(object) graph_component_position_changed = Signal(object, object, object) graph_component_label_changed = Signal(object, object, object) graph_connection_points_changed = Signal(object, object, object) @@ -140,6 +142,40 @@ class Document(QObject): database = self._graph_database(False) return deepcopy(database) if database is not None else GraphDatabase() + def stored_port_metadata_database(self) -> PortMetadataDatabase | None: + database = self._port_metadata_database(False) + return deepcopy(database) if database is not None else None + + def port_metadata_database(self) -> PortMetadataDatabase: + return self.stored_port_metadata_database() or PortMetadataDatabase() + + def port_metadata(self, port_id: PortID) -> PortMetadata: + return deepcopy(self.port_metadata_database().ports.get(port_id, PortMetadata())) + + def _set_port_metadata_database(self, database: PortMetadataDatabase | None) -> None: + if database is None or not database.ports: + if self.model.metadata is not None: + self.model.metadata.pop("port_metadata_database", None) + else: + if self.model.metadata is None: + self.model.metadata = {} + self.model.metadata["port_metadata_database"] = deepcopy(database) + self.port_metadata_database_changed.emit(self.stored_port_metadata_database()) + + def _port_metadata_database(self, create: bool) -> PortMetadataDatabase | None: + metadata = self.model.metadata + value = metadata.get("port_metadata_database") if metadata is not None else None + if isinstance(value, PortMetadataDatabase): + return value + if not create: + return None + if metadata is None: + metadata = {} + self.model.metadata = metadata + database = PortMetadataDatabase() + metadata["port_metadata_database"] = database + return database + def move_graph_component(self, graph_component: Component, component_id: ComponentID, position: tuple[int, int]) -> None: graph_id = self.component_id(graph_component) database = self._graph_database(False) @@ -147,6 +183,18 @@ class Document(QObject): if graph is None or graph.component_positions.get(component_id) != position: self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position)) + def move_graph_components(self, graph_component: Component, positions: dict[ComponentID, tuple[int, int]]) -> None: + graph_id = self.component_id(graph_component) + database = self._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + changed = {component_id: position for component_id, position in positions.items() if graph is None or graph.component_positions.get(component_id) != position} + if not changed: + return + self.undo_stack.beginMacro("Move graph components" if len(changed) > 1 else "Move graph component") + for component_id, position in changed.items(): + self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position)) + self.undo_stack.endMacro() + def _set_graph_component_position(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None: if position is None: database = self._graph_database(False) @@ -309,7 +357,7 @@ class Document(QObject): metadata["icon_database"] = database return database - def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None: + def update_component_ports(self, component: Component, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: current = component.interface.ports removed = [ RemovePortCommand(self, component, port_id) @@ -325,12 +373,23 @@ class Document(QObject): if current[port_id] != ports[port_id] ] commands = [*removed, *added, *changed] - if not commands: + database = self.port_metadata_database() + component_port_ids = set(current) | set(ports) + for port_id in component_port_ids: + metadata = port_metadata.get(port_id, PortMetadata()) if port_metadata is not None and port_id in ports else PortMetadata() + if metadata == PortMetadata(): + database.ports.pop(port_id, None) + else: + database.ports[port_id] = deepcopy(metadata) + metadata_changed = database != self.port_metadata_database() + if not commands and not metadata_changed: return self.undo_stack.beginMacro("Edit interface") for command in commands: self.undo_stack.push(command) + if metadata_changed: + self.undo_stack.push(ChangePortMetadataDatabaseCommand(self, database)) self.undo_stack.endMacro() @@ -396,16 +455,16 @@ class Document(QObject): self.undo_stack.push(DeleteComponent(self, component)) self.undo_stack.endMacro() - def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: + def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if not components: return names = {component.name for component in target.values()} for component in components.values(): component.name = self._unique_name(names, component.name) names.add(component.name) - self.undo_stack.push(PasteComponents(self, target, components, icons)) + self.undo_stack.push(PasteComponents(self, target, components, icons, port_metadata=port_metadata)) - def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]]) -> None: + def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if not isinstance(graph_component.implementation, GraphImplementation) or not components: return target = graph_component.implementation.graph.components @@ -413,7 +472,7 @@ class Document(QObject): for component in components.values(): component.name = self._unique_name(names, component.name) names.add(component.name) - self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions)) + self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions, port_metadata)) @staticmethod def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str: diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py index ae37934..8834269 100644 --- a/src/bedit_gui/models.py +++ b/src/bedit_gui/models.py @@ -143,6 +143,31 @@ class IconDatabase: def to_data(self) -> dict[str, Any]: return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}} +@dataclass +class PortMetadata: + connection_annotation: str | None = None + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> PortMetadata: + annotation = data.get("connection_annotation") + return cls(connection_annotation=str(annotation) if annotation else None) + + def to_data(self) -> dict[str, Any]: + return {"connection_annotation": self.connection_annotation} + +@dataclass +class PortMetadataDatabase: + format_version: int = 1 + ports: dict[PortID, PortMetadata] = field(default_factory=dict) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> PortMetadataDatabase: + ports = {PortID(key): PortMetadata.from_data(value) for key, value in data.get("ports", {}).items()} + return cls(format_version=int(data.get("format_version", 1)), ports=ports) + + def to_data(self) -> dict[str, Any]: + return {"format_version": self.format_version, "ports": {str(key): metadata.to_data() for key, metadata in self.ports.items()}} + @dataclass class GraphConnection: points: list[tuple[int, int]] = field(default_factory=list) diff --git a/src/bedit_gui/services/component_clipboard.py b/src/bedit_gui/services/component_clipboard.py index 1c3fa6d..a14b114 100644 --- a/src/bedit_gui/services/component_clipboard.py +++ b/src/bedit_gui/services/component_clipboard.py @@ -6,7 +6,7 @@ from typing import Any from bedit_core.models import Component, ComponentID, ConnectionID, Document, GraphImplementation, ID, ParameterID, PortID from bedit_core.serialization.schema import document_from_data, document_to_data from bedit_gui.documents import Document as GuiDocument -from bedit_gui.models import Icon, ShapeID +from bedit_gui.models import Icon, PortMetadata, ShapeID FORMAT_VERSION = 1 COMPONENTS_MIME = "application/x-bedit-components+json" @@ -16,21 +16,27 @@ def export_components(document: GuiDocument, components: list[Component]) -> dic roots = {document.component_id(component): component for component in components} component_ids = _all_component_ids(roots) icons = {} + port_metadata = {} for component_id in component_ids: icon = document.stored_component_icon(component_id) if icon is not None: icons[component_id] = icon - return export_component_data(roots, icons) + database = document.port_metadata_database() + for port_id in _all_port_ids(roots): + if port_id in database.ports: + port_metadata[port_id] = database.ports[port_id] + return export_component_data(roots, icons, port_metadata) -def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> dict[str, Any]: +def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> dict[str, Any]: serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=components)) component_ids = set(_all_component_ids(components)) icon_data = {str(component_id): icon.to_data() for component_id, icon in icons.items() if component_id in component_ids} - return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data} + metadata_data = {str(port_id): metadata.to_data() for port_id, metadata in (port_metadata or {}).items() if port_id in set(_all_port_ids(components))} + return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data, "port_metadata": metadata_data} -def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon]]: +def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon], dict[PortID, PortMetadata]]: if payload.get("format_version") != FORMAT_VERSION or payload.get("type") != "components": raise ValueError("unsupported component clipboard format") components = payload.get("components") @@ -52,7 +58,11 @@ def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Compon icon.shapes = {ShapeID(): shape for shape in icon.shapes.values()} icon.port_positions = {port_map[port_id]: position for port_id, position in icon.port_positions.items() if port_id in port_map} icons[new_component_id] = icon - return remapped, icons + metadata_data = payload.get("port_metadata", {}) + if not isinstance(metadata_data, dict): + raise TypeError("component clipboard port metadata must be an object") + port_metadata = {port_map[PortID(old_id)]: PortMetadata.from_data(data) for old_id, data in metadata_data.items() if PortID(old_id) in port_map and isinstance(data, dict)} + return remapped, icons, port_metadata def _remap_components(components: dict[ComponentID, Component], component_map: dict[ComponentID, ComponentID], port_map: dict[PortID, PortID]) -> dict[ComponentID, Component]: @@ -87,3 +97,12 @@ def _all_component_ids(components: dict[ComponentID, Component]) -> list[Compone if isinstance(component.implementation, GraphImplementation): component_ids.extend(_all_component_ids(component.implementation.graph.components)) return component_ids + + +def _all_port_ids(components: dict[ComponentID, Component]) -> list[PortID]: + port_ids: list[PortID] = [] + for component in components.values(): + port_ids.extend(component.interface.ports) + if isinstance(component.implementation, GraphImplementation): + port_ids.extend(_all_port_ids(component.implementation.graph.components)) + return port_ids diff --git a/src/bedit_gui/services/document_files.py b/src/bedit_gui/services/document_files.py index 8bdc676..cbafac2 100644 --- a/src/bedit_gui/services/document_files.py +++ b/src/bedit_gui/services/document_files.py @@ -6,7 +6,7 @@ from pathlib import Path from bedit_core.models import Document from bedit_core.serialization import load as load_document from bedit_core.serialization import save as save_document -from bedit_gui.models import GraphDatabase, IconDatabase, SimulationDatabase +from bedit_gui.models import GraphDatabase, IconDatabase, PortMetadataDatabase, SimulationDatabase def load(path: str | Path) -> Document: @@ -18,6 +18,8 @@ def load(path: str | Path) -> Document: document.metadata["graph_database"] = GraphDatabase.from_data(document.metadata["graph_database"]) if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict): document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"]) + if document.metadata is not None and isinstance(document.metadata.get("port_metadata_database"), dict): + document.metadata["port_metadata_database"] = PortMetadataDatabase.from_data(document.metadata["port_metadata_database"]) return document @@ -30,4 +32,6 @@ def save(document: Document, path: str | Path) -> None: saved_document.metadata["graph_database"] = saved_document.metadata["graph_database"].to_data() if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase): saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data() + if saved_document.metadata is not None and isinstance(saved_document.metadata.get("port_metadata_database"), PortMetadataDatabase): + saved_document.metadata["port_metadata_database"] = saved_document.metadata["port_metadata_database"].to_data() save_document(saved_document, path) diff --git a/src/bedit_gui/ui/forms/port_editor_widget.ui b/src/bedit_gui/ui/forms/port_editor_widget.ui index 869d71f..da3fe60 100644 --- a/src/bedit_gui/ui/forms/port_editor_widget.ui +++ b/src/bedit_gui/ui/forms/port_editor_widget.ui @@ -148,6 +148,20 @@ + + + + Connection annotation: + + + + + + + For example, + or - + + + diff --git a/src/bedit_gui/ui/forms/port_editor_widget_ui.py b/src/bedit_gui/ui/forms/port_editor_widget_ui.py new file mode 100644 index 0000000..0b33c33 --- /dev/null +++ b/src/bedit_gui/ui/forms/port_editor_widget_ui.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'port_editor_widget.ui' +## +## Created by: Qt User Interface Compiler version 6.11.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFormLayout, + QFrame, QHBoxLayout, QLabel, QLineEdit, + QListView, QPlainTextEdit, QPushButton, QRadioButton, + QSizePolicy, QSpacerItem, QSpinBox, QVBoxLayout, + QWidget) + +class Ui_PortEditor(object): + def setupUi(self, PortEditor): + if not PortEditor.objectName(): + PortEditor.setObjectName(u"PortEditor") + PortEditor.resize(541, 420) + self.horizontalLayout_2 = QHBoxLayout(PortEditor) + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.leftColumn = QVBoxLayout() + self.leftColumn.setObjectName(u"leftColumn") + self.portList = QListView(PortEditor) + self.portList.setObjectName(u"portList") + + self.leftColumn.addWidget(self.portList) + + self.buttonRow = QHBoxLayout() + self.buttonRow.setObjectName(u"buttonRow") + self.addPort = QPushButton(PortEditor) + self.addPort.setObjectName(u"addPort") + + self.buttonRow.addWidget(self.addPort) + + self.removePort = QPushButton(PortEditor) + self.removePort.setObjectName(u"removePort") + + self.buttonRow.addWidget(self.removePort) + + + self.leftColumn.addLayout(self.buttonRow) + + + self.horizontalLayout_2.addLayout(self.leftColumn) + + self.rightColumn = QVBoxLayout() + self.rightColumn.setObjectName(u"rightColumn") + self.basicForm = QFormLayout() + self.basicForm.setObjectName(u"basicForm") + self.nameLabel = QLabel(PortEditor) + self.nameLabel.setObjectName(u"nameLabel") + + self.basicForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel) + + self.nameEdit = QLineEdit(PortEditor) + self.nameEdit.setObjectName(u"nameEdit") + + self.basicForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit) + + self.typeLabel = QLabel(PortEditor) + self.typeLabel.setObjectName(u"typeLabel") + + self.basicForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel) + + self.typeRow = QHBoxLayout() + self.typeRow.setObjectName(u"typeRow") + self.typeSignal = QRadioButton(PortEditor) + self.typeSignal.setObjectName(u"typeSignal") + + self.typeRow.addWidget(self.typeSignal) + + self.typeBond = QRadioButton(PortEditor) + self.typeBond.setObjectName(u"typeBond") + + self.typeRow.addWidget(self.typeBond) + + + self.basicForm.setLayout(1, QFormLayout.ItemRole.FieldRole, self.typeRow) + + self.orientationLabel = QLabel(PortEditor) + self.orientationLabel.setObjectName(u"orientationLabel") + + self.basicForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.orientationLabel) + + self.orientationRow = QHBoxLayout() + self.orientationRow.setObjectName(u"orientationRow") + self.inputOrientation = QRadioButton(PortEditor) + self.inputOrientation.setObjectName(u"inputOrientation") + + self.orientationRow.addWidget(self.inputOrientation) + + self.outputOrientation = QRadioButton(PortEditor) + self.outputOrientation.setObjectName(u"outputOrientation") + + self.orientationRow.addWidget(self.outputOrientation) + + + self.basicForm.setLayout(2, QFormLayout.ItemRole.FieldRole, self.orientationRow) + + self.sizeLabel = QLabel(PortEditor) + self.sizeLabel.setObjectName(u"sizeLabel") + + self.basicForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.sizeLabel) + + self.sizeRow = QHBoxLayout() + self.sizeRow.setObjectName(u"sizeRow") + self.widthSize = QSpinBox(PortEditor) + self.widthSize.setObjectName(u"widthSize") + self.widthSize.setMinimum(1) + + self.sizeRow.addWidget(self.widthSize) + + self.heightSize = QSpinBox(PortEditor) + self.heightSize.setObjectName(u"heightSize") + self.heightSize.setMinimum(1) + + self.sizeRow.addWidget(self.heightSize) + + + self.basicForm.setLayout(3, QFormLayout.ItemRole.FieldRole, self.sizeRow) + + self.domainLabel = QLabel(PortEditor) + self.domainLabel.setObjectName(u"domainLabel") + + self.basicForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.domainLabel) + + self.multiplicityCheckBox = QCheckBox(PortEditor) + self.multiplicityCheckBox.setObjectName(u"multiplicityCheckBox") + + self.basicForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.multiplicityCheckBox) + + self.connectionAnnotationLabel = QLabel(PortEditor) + self.connectionAnnotationLabel.setObjectName(u"connectionAnnotationLabel") + + self.basicForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.connectionAnnotationLabel) + + self.connectionAnnotationEdit = QLineEdit(PortEditor) + self.connectionAnnotationEdit.setObjectName(u"connectionAnnotationEdit") + + self.basicForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.connectionAnnotationEdit) + + + self.rightColumn.addLayout(self.basicForm) + + self.line = QFrame(PortEditor) + self.line.setObjectName(u"line") + self.line.setFrameShape(QFrame.Shape.HLine) + self.line.setFrameShadow(QFrame.Shadow.Sunken) + + self.rightColumn.addWidget(self.line) + + self.signalOptions = QFormLayout() + self.signalOptions.setObjectName(u"signalOptions") + self.signalTypeLabel = QLabel(PortEditor) + self.signalTypeLabel.setObjectName(u"signalTypeLabel") + + self.signalOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.signalTypeLabel) + + self.signalTypeComboBox = QComboBox(PortEditor) + self.signalTypeComboBox.setObjectName(u"signalTypeComboBox") + + self.signalOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.signalTypeComboBox) + + + self.rightColumn.addLayout(self.signalOptions) + + self.bondOptions = QFormLayout() + self.bondOptions.setObjectName(u"bondOptions") + self.domainLabel_2 = QLabel(PortEditor) + self.domainLabel_2.setObjectName(u"domainLabel_2") + + self.bondOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.domainLabel_2) + + self.domainComboBox = QComboBox(PortEditor) + self.domainComboBox.setObjectName(u"domainComboBox") + + self.bondOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.domainComboBox) + + self.causalityLabel = QLabel(PortEditor) + self.causalityLabel.setObjectName(u"causalityLabel") + + self.bondOptions.setWidget(1, QFormLayout.ItemRole.LabelRole, self.causalityLabel) + + self.causalityComboBox = QComboBox(PortEditor) + self.causalityComboBox.setObjectName(u"causalityComboBox") + + self.bondOptions.setWidget(1, QFormLayout.ItemRole.FieldRole, self.causalityComboBox) + + + self.rightColumn.addLayout(self.bondOptions) + + self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.rightColumn.addItem(self.verticalSpacer) + + self.line_2 = QFrame(PortEditor) + self.line_2.setObjectName(u"line_2") + self.line_2.setFrameShape(QFrame.Shape.HLine) + self.line_2.setFrameShadow(QFrame.Shadow.Sunken) + + self.rightColumn.addWidget(self.line_2) + + self.descriptionForm = QFormLayout() + self.descriptionForm.setObjectName(u"descriptionForm") + self.descriptionForm.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + self.descriptionLabel = QLabel(PortEditor) + self.descriptionLabel.setObjectName(u"descriptionLabel") + + self.descriptionForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.descriptionLabel) + + self.descriptionEdit = QPlainTextEdit(PortEditor) + self.descriptionEdit.setObjectName(u"descriptionEdit") + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.descriptionEdit.sizePolicy().hasHeightForWidth()) + self.descriptionEdit.setSizePolicy(sizePolicy) + self.descriptionEdit.setMinimumSize(QSize(0, 20)) + self.descriptionEdit.setMaximumSize(QSize(16777215, 60)) + + self.descriptionForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.descriptionEdit) + + + self.rightColumn.addLayout(self.descriptionForm) + + + self.horizontalLayout_2.addLayout(self.rightColumn) + + + self.retranslateUi(PortEditor) + + QMetaObject.connectSlotsByName(PortEditor) + # setupUi + + def retranslateUi(self, PortEditor): + PortEditor.setWindowTitle(QCoreApplication.translate("PortEditor", u"Form", None)) + self.addPort.setText(QCoreApplication.translate("PortEditor", u"Add Port", None)) + self.removePort.setText(QCoreApplication.translate("PortEditor", u"Remove Port", None)) + self.nameLabel.setText(QCoreApplication.translate("PortEditor", u"Name:", None)) + self.typeLabel.setText(QCoreApplication.translate("PortEditor", u"Type:", None)) + self.typeSignal.setText(QCoreApplication.translate("PortEditor", u"Signal", None)) + self.typeBond.setText(QCoreApplication.translate("PortEditor", u"Power Bond", None)) + self.orientationLabel.setText(QCoreApplication.translate("PortEditor", u"Orientation:", None)) + self.inputOrientation.setText(QCoreApplication.translate("PortEditor", u"Input", None)) + self.outputOrientation.setText(QCoreApplication.translate("PortEditor", u"Output", None)) + self.sizeLabel.setText(QCoreApplication.translate("PortEditor", u"Size", None)) + self.widthSize.setSuffix(QCoreApplication.translate("PortEditor", u" rows", None)) + self.heightSize.setSuffix(QCoreApplication.translate("PortEditor", u" columns", None)) + self.domainLabel.setText("") + self.multiplicityCheckBox.setText(QCoreApplication.translate("PortEditor", u"Allow multiple connections", None)) + self.connectionAnnotationLabel.setText(QCoreApplication.translate("PortEditor", u"Connection annotation:", None)) + self.connectionAnnotationEdit.setPlaceholderText(QCoreApplication.translate("PortEditor", u"For example, + or -", None)) + self.signalTypeLabel.setText(QCoreApplication.translate("PortEditor", u"Signal Type:", None)) + self.domainLabel_2.setText(QCoreApplication.translate("PortEditor", u"Domain:", None)) + self.causalityLabel.setText(QCoreApplication.translate("PortEditor", u"Causality:", None)) + self.descriptionLabel.setText(QCoreApplication.translate("PortEditor", u"Description:", None)) + # retranslateUi + diff --git a/src/bedit_gui/views/dialogs/interface_editor_dialog.py b/src/bedit_gui/views/dialogs/interface_editor_dialog.py index 2877f10..82f7747 100644 --- a/src/bedit_gui/views/dialogs/interface_editor_dialog.py +++ b/src/bedit_gui/views/dialogs/interface_editor_dialog.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import ( ) from bedit_core.models import Port, PortID +from bedit_gui.models import PortMetadata from bedit_gui.views.port_editor_widget import PortEditorWidget @@ -17,6 +18,7 @@ class InterfaceEditorDialog(QDialog): def __init__( self, ports: dict[PortID, Port], + port_metadata: dict[PortID, PortMetadata] | None = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) @@ -25,7 +27,7 @@ class InterfaceEditorDialog(QDialog): layout = QVBoxLayout(self) self.editor = PortEditorWidget(self) - self.editor.set_ports(ports) + self.editor.set_ports(ports, port_metadata) layout.addWidget(self.editor) buttons = QDialogButtonBox( @@ -38,3 +40,6 @@ class InterfaceEditorDialog(QDialog): def ports(self) -> dict[PortID, Port]: return self.editor.ports() + + def port_metadata(self) -> dict[PortID, PortMetadata]: + return self.editor.port_metadata() diff --git a/src/bedit_gui/views/equation_editor_widget.py b/src/bedit_gui/views/equation_editor_widget.py index 0ae8a6f..481f183 100644 --- a/src/bedit_gui/views/equation_editor_widget.py +++ b/src/bedit_gui/views/equation_editor_widget.py @@ -3,7 +3,8 @@ from __future__ import annotations from PySide6.QtCore import QEvent, QObject, QTimer, Qt, Signal from PySide6.QtWidgets import QWidget -from bedit_core.models import Component, EquationImplementation +from bedit_core.models import Component, EquationImplementation, PortID +from bedit_gui.models import PortMetadata from bedit_gui.ui.generated.ui_equation_editor_widget import Ui_equationEditorWidget from bedit_gui.views.param_editor_widget import ParamEditorWidget from bedit_gui.views.port_editor_widget import PortEditorWidget @@ -14,6 +15,7 @@ class EquationEditorWidget(QWidget): component_changed = Signal(object) equation_text_change_requested = Signal(object, str, object, int) + port_metadata_change_requested = Signal(object, object) sidebar_visible_changed = Signal(bool) def __init__(self, parent: QWidget | None = None) -> None: @@ -22,6 +24,7 @@ class EquationEditorWidget(QWidget): self.ui = Ui_equationEditorWidget() self.ui.setupUi(self) self._component: Component | None = None + self._port_metadata: dict[PortID, PortMetadata] = {} self._loading = False self._edit_id = 0 self._edit_timer = QTimer(self) @@ -45,11 +48,12 @@ class EquationEditorWidget(QWidget): self._set_editors_enabled(False) - def set_component(self, component: Component | None) -> None: + def set_component(self, component: Component | None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if component is not None and not isinstance(component.implementation, EquationImplementation): raise TypeError("EquationEditorWidget only supports components with an equation implementation") self._component = component + self._port_metadata = port_metadata or {} self.refresh() def component(self) -> Component | None: @@ -88,7 +92,7 @@ class EquationEditorWidget(QWidget): self.ui.initialEquationsTextEdit.setPlainText("\n".join(implementation.initial_equations)) self.ui.equationsTextEdit.setPlainText("\n".join(implementation.equations)) self.param_editor.set_params(component.parameters) - self.port_editor.set_ports(component.interface.ports) + self.port_editor.set_ports(component.interface.ports, self._port_metadata) self._loading = False self._set_editors_enabled(component is not None) @@ -145,8 +149,21 @@ class EquationEditorWidget(QWidget): def _ports_changed(self) -> None: if self._loading or self._component is None: return - self._component.interface.ports = self.port_editor.ports() - self.component_changed.emit(self._component) + ports = self.port_editor.ports() + metadata = self.port_editor.port_metadata() + if ports != self._component.interface.ports: + self._component.interface.ports = ports + self.component_changed.emit(self._component) + if metadata != self._port_metadata: + self._port_metadata = metadata + self.port_metadata_change_requested.emit(self._component, metadata) + + def refresh_port_metadata(self, port_metadata: dict[PortID, PortMetadata]) -> None: + if port_metadata == self._port_metadata: + return + self._port_metadata = port_metadata + if self._component is not None: + self.port_editor.set_ports(self._component.interface.ports, port_metadata) def _replace_placeholder(self, placeholder: QWidget, editor: QWidget) -> None: self.ui.verticalLayout_2.replaceWidget(placeholder, editor) diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py index d0e8c8e..eb67ad7 100644 --- a/src/bedit_gui/views/graph_editor_widget.py +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -10,7 +10,7 @@ from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QDragEnterEvent from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsTextItem, QGraphicsView, QMenu, QWidget from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort -from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon +from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon, PortMetadata from bedit_gui.services.component_clipboard import COMPONENTS_MIME from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget from bedit_gui.utils.icon import get_natural_icon_size, get_pixmap_bounding_box, render_icon @@ -34,6 +34,9 @@ ARROW_HALF_WIDTH = 16.0 SIGNAL_ARROW_LENGTH = ARROW_LENGTH / 2 SIGNAL_ARROW_HALF_WIDTH = ARROW_HALF_WIDTH / 2 CAUSALITY_TICK_HALF_LENGTH = 16.0 +CONNECTION_ANNOTATION_FONT_SIZE = 18.0 +CONNECTION_ANNOTATION_BACK_OFFSET = 24.0 +CONNECTION_ANNOTATION_SIDE_OFFSET = 14.0 class GraphEditorMode(Enum): @@ -152,7 +155,7 @@ class GraphComponentItem(QGraphicsPixmapItem): self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object: - if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF): + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.component_id in self.editor._component_drag_starts and isinstance(value, QPointF): grid_size = self.editor.snap_to_grid_size value = QPointF(round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size) result = super().itemChange(change, value) @@ -167,6 +170,7 @@ class GraphComponentItem(QGraphicsPixmapItem): self._drag_start = QPointF(self.pos()) self._dragging = True super().mousePressEvent(event) + self.editor.begin_component_move() def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self.editor.mode is GraphEditorMode.CONNECTION: @@ -176,10 +180,7 @@ class GraphComponentItem(QGraphicsPixmapItem): return super().mouseReleaseEvent(event) self._dragging = False - position = (round(self.pos().x()), round(self.pos().y())) - self.setPos(*position) - if self.pos() != self._drag_start: - self.editor.finish_component_move(self.component_id, position) + self.editor.finish_component_moves() def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: if event.button() == Qt.MouseButton.LeftButton: @@ -295,7 +296,7 @@ class GraphConnectionPointItem(QGraphicsEllipseItem): class GraphEditorWidget(QWidget): - component_move_requested = Signal(object, object, object) + component_moves_requested = Signal(object, object) component_context_menu_requested = Signal(object, object) component_open_requested = Signal(object) component_label_move_requested = Signal(object, object, object) @@ -312,14 +313,17 @@ class GraphEditorWidget(QWidget): self._component: Component | None = None self._graph = Graph() self._icons: dict[ComponentID, Icon] = {} + self._port_metadata: dict[PortID, PortMetadata] = {} self._mode = GraphEditorMode.NORMAL self._connection_start: ComponentID | None = None self._connection_preview: QGraphicsPathItem | None = None self._component_items: dict[ComponentID, GraphComponentItem] = {} + self._component_drag_starts: dict[ComponentID, tuple[int, int]] = {} self._component_bounds: dict[ComponentID, QRectF] = {} self._component_label_items: dict[ComponentID, GraphComponentLabelItem] = {} self._connection_items: dict[ConnectionID, GraphConnectionItem] = {} self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {} + self._connection_annotation_items: dict[ConnectionID, QGraphicsTextItem] = {} self.set_snap_to_grid_size(snap_to_grid_size) self.scene = GraphGraphicsScene(self) self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE) @@ -364,12 +368,12 @@ class GraphEditorWidget(QWidget): self._clear_connection_start() component = self._component if component is not None: - self.set_component(component, self._graph, self._icons) + self.set_component(component, self._graph, self._icons, self._port_metadata) def toggle_mode(self) -> None: self.set_mode(GraphEditorMode.CONNECTION if self._mode is GraphEditorMode.NORMAL else GraphEditorMode.NORMAL) - def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None) -> None: + def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if component is not None and not isinstance(component.implementation, GraphImplementation): raise TypeError("GraphEditorWidget only supports components with a graph implementation") component_changed = component is not self._component @@ -377,11 +381,14 @@ class GraphEditorWidget(QWidget): self._component = component self._graph = graph or Graph() self._icons = icons or {} + self._port_metadata = port_metadata or {} + self._component_drag_starts = {} self._component_items = {} self._component_bounds = {} self._component_label_items = {} self._connection_items = {} self._connection_point_items = {} + self._connection_annotation_items = {} self.scene.clear() if component is None: return @@ -420,6 +427,17 @@ class GraphEditorWidget(QWidget): connection_item.setData(0, str(connection_id)) self._connection_items[connection_id] = connection_item self.scene.addItem(connection_item) + annotation = self._port_metadata.get(connection.target, PortMetadata()).connection_annotation if isinstance(connection, SignalConnection) else None + if annotation: + annotation_item = QGraphicsTextItem(annotation) + font = annotation_item.font() + font.setBold(True) + font.setPointSizeF(CONNECTION_ANNOTATION_FONT_SIZE) + annotation_item.setFont(font) + annotation_item.setDefaultTextColor(QColor(SIGNAL_CONNECTION_COLOR)) + annotation_item.setZValue(1) + self._connection_annotation_items[connection_id] = annotation_item + self.scene.addItem(annotation_item) visual_connection = graph.connections.get(connection_id) self._create_connection_point_items(connection_id, visual_connection.points[1:-1] if visual_connection is not None and len(visual_connection.points) >= 2 else []) self.refresh_connections() @@ -452,6 +470,23 @@ class GraphEditorWidget(QWidget): if isinstance(connection, BondConnection): tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source)) + annotation_item = self._connection_annotation_items.get(connection_id) + if annotation_item is not None: + self._position_connection_annotation(annotation_item, points) + + @staticmethod + def _position_connection_annotation(item: QGraphicsTextItem, points: list[tuple[float, float]]) -> None: + target = QPointF(*points[-1]) + previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None) + if previous is None: + return + dx = target.x() - previous.x() + dy = target.y() - previous.y() + length = hypot(dx, dy) + x = target.x() - CONNECTION_ANNOTATION_BACK_OFFSET * dx / length - CONNECTION_ANNOTATION_SIDE_OFFSET * dy / length + y = target.y() - CONNECTION_ANNOTATION_BACK_OFFSET * dy / length + CONNECTION_ANNOTATION_SIDE_OFFSET * dx / length + bounds = item.boundingRect() + item.setPos(x - bounds.width() / 2, y - bounds.height() / 2) def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None: points = self._connection_metadata_points(connection_id) @@ -477,6 +512,9 @@ class GraphEditorWidget(QWidget): connection_item = self._connection_items.pop(connection_id, None) if connection_item is not None: self.scene.removeItem(connection_item) + annotation_item = self._connection_annotation_items.pop(connection_id, None) + if annotation_item is not None: + self.scene.removeItem(annotation_item) return self._graph.connections[connection_id] = GraphConnection(points=list(points)) interior_points = points[1:-1] @@ -532,9 +570,23 @@ class GraphEditorWidget(QWidget): best_distance = distance return best_index - def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None: - if self._component is not None: - self.component_move_requested.emit(self._component, component_id, position) + def begin_component_move(self) -> None: + self._component_drag_starts = {item.component_id: (round(item.pos().x()), round(item.pos().y())) for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)} + + def finish_component_moves(self) -> None: + starts = self._component_drag_starts + self._component_drag_starts = {} + if self._component is None: + return + positions = {} + for component_id, old_position in starts.items(): + item = self._component_items[component_id] + position = (round(item.pos().x()), round(item.pos().y())) + item.setPos(*position) + if position != old_position: + positions[component_id] = position + if positions: + self.component_moves_requested.emit(self._component, positions) def finish_component_label_move(self, component_id: ComponentID, relative_position: tuple[int, int]) -> None: if self._component is not None: diff --git a/src/bedit_gui/views/port_editor_widget.py b/src/bedit_gui/views/port_editor_widget.py index 90a64d7..a9a45ff 100644 --- a/src/bedit_gui/views/port_editor_widget.py +++ b/src/bedit_gui/views/port_editor_widget.py @@ -7,6 +7,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import QButtonGroup, QLayout, QWidget from bedit_core.models import BondPort, Port, PortCausality, PortID, SignalDirection, SignalPort, ValueType +from bedit_gui.models import PortMetadata from bedit_gui.ui.generated.ui_port_editor_widget import Ui_PortEditor @@ -21,6 +22,7 @@ class PortEditorWidget(QWidget): self.ui = Ui_PortEditor() self.ui.setupUi(self) self._ports: dict[PortID, Port] = {} + self._port_metadata: dict[PortID, PortMetadata] = {} self._port_ids: list[PortID] = [] self._loading = False @@ -54,6 +56,7 @@ class PortEditorWidget(QWidget): self.ui.widthSize.valueChanged.connect(self._form_changed) self.ui.heightSize.valueChanged.connect(self._form_changed) self.ui.multiplicityCheckBox.toggled.connect(self._form_changed) + self.ui.connectionAnnotationEdit.textChanged.connect(self._form_changed) self.ui.signalTypeComboBox.currentIndexChanged.connect(self._form_changed) self.ui.domainComboBox.currentTextChanged.connect(self._form_changed) self.ui.causalityComboBox.currentIndexChanged.connect(self._form_changed) @@ -62,14 +65,18 @@ class PortEditorWidget(QWidget): self._set_editor_enabled(False) self._update_option_visibility() - def set_ports(self, ports: dict[PortID, Port]) -> None: + def set_ports(self, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: self._ports = deepcopy(ports) + self._port_metadata = deepcopy(port_metadata or {}) self._port_ids = list(self._ports) self._rebuild_list() def ports(self) -> dict[PortID, Port]: return deepcopy(self._ports) + def port_metadata(self) -> dict[PortID, PortMetadata]: + return deepcopy(self._port_metadata) + def _rebuild_list(self, selected_id: PortID | None = None) -> None: self._list_model.clear() for port_id in self._port_ids: @@ -90,7 +97,7 @@ class PortEditorWidget(QWidget): self.ui.removePort.setEnabled(port_id is not None) self._set_editor_enabled(port_id is not None) if port_id is not None: - self._load_port(self._ports[port_id]) + self._load_port(self._ports[port_id], self._port_metadata.get(port_id, PortMetadata())) def _selected_port_id(self) -> PortID | None: index = self.ui.portList.currentIndex() @@ -98,7 +105,7 @@ class PortEditorWidget(QWidget): return None return self._port_ids[index.row()] - def _load_port(self, port: Port) -> None: + def _load_port(self, port: Port, metadata: PortMetadata) -> None: self._loading = True self.ui.nameEdit.setText(port.name) self.ui.inputOrientation.setChecked(port.direction is SignalDirection.INPUT) @@ -106,6 +113,7 @@ class PortEditorWidget(QWidget): self.ui.widthSize.setValue(port.matrix_size[0]) self.ui.heightSize.setValue(port.matrix_size[1]) self.ui.multiplicityCheckBox.setChecked(port.multiplicity) + self.ui.connectionAnnotationEdit.setText(metadata.connection_annotation or "") self.ui.descriptionEdit.setPlainText(port.description or "") if isinstance(port, SignalPort): @@ -135,6 +143,12 @@ class PortEditorWidget(QWidget): old_port = self._ports[port_id] self._ports[port_id] = self._port_from_form(old_port) + annotation = (self.ui.connectionAnnotationEdit.text().strip() or None) if self.ui.multiplicityCheckBox.isChecked() else None + metadata = PortMetadata(connection_annotation=annotation) + if metadata == PortMetadata(): + self._port_metadata.pop(port_id, None) + else: + self._port_metadata[port_id] = metadata self._list_model.item(self._port_ids.index(port_id)).setText( self._ports[port_id].name ) @@ -188,6 +202,7 @@ class PortEditorWidget(QWidget): return row = self._port_ids.index(port_id) del self._ports[port_id] + self._port_metadata.pop(port_id, None) self._port_ids.remove(port_id) selected = ( self._port_ids[min(row, len(self._port_ids) - 1)] @@ -201,6 +216,9 @@ class PortEditorWidget(QWidget): signal = self.ui.typeSignal.isChecked() self._set_layout_visible(self.ui.signalOptions, signal) self._set_layout_visible(self.ui.bondOptions, not signal) + annotation_visible = self.ui.multiplicityCheckBox.isChecked() + self.ui.connectionAnnotationLabel.setVisible(annotation_visible) + self.ui.connectionAnnotationEdit.setVisible(annotation_visible) @staticmethod def _set_layout_visible(layout: QLayout, visible: bool) -> None: diff --git a/untitled.bedit.json b/untitled.bedit.json index 2fea8ac..6e8be13 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -608,11 +608,219 @@ ] } }, - "ac5011ce-4659-4bdd-900a-63a1c779652c": { + "404e82f4-19e2-44ad-b409-3f021e483a39": { + "name": "integrator", + "interface": { + "ports": { + "441a44d0-bbe5-49f0-bb0e-5bc891218aca": { + "port_type": "signal", + "name": "y", + "direction": "output", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + }, + "8d4b0f2c-2e13-4cfd-a8da-6c03ade4fbbf": { + "port_type": "signal", + "name": "u", + "direction": "input", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + } + } + }, + "parameters": { + "7150c82e-8c78-4f83-9bc8-e46057a367a8": { + "name": "k", + "value": "1.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Integrator gain" + }, + "f7b9691a-22a4-41e0-9871-44f8dd0e02d2": { + "name": "initType", + "value": "3", + "value_type": "int", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Type of initialization (1: no init, 2: steady state, 3: initial state, 4: initial output)" + }, + "3b636a9c-257c-4368-855a-094051f29041": { + "name": "y_start", + "value": "0.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Initial or guess value of output (= state)" + } + }, + "implementation": { + "implementation_type": "equation", + "declarations": [], + "initial_equations": [ + "if initType == 2 then", + "\tder(y) = 0;", + "elseif initType >= 3 then", + "\ty = y_start;", + "end if;" + ], + "equations": [ + "der(y) = k*u;" + ] + } + }, + "2b0156f6-9977-4735-88dd-14f5eb3d87b5": { + "name": "derivative", + "interface": { + "ports": { + "d61847c9-0628-4e86-ad91-9390f92f926d": { + "port_type": "signal", + "name": "y", + "direction": "output", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + }, + "f18c8fd2-dda5-4e8d-a5a7-a22a38abb091": { + "port_type": "signal", + "name": "u", + "direction": "input", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + } + } + }, + "parameters": { + "ec421126-1ff4-4627-8bdd-1841318f3eb6": { + "name": "k", + "value": "1.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Integrator gain" + }, + "0c364769-5f07-4f3e-bb93-91d86a1f2dae": { + "name": "initType", + "value": "3", + "value_type": "int", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Type of initialization (1: no init, 2: steady state, 3: initial state, 4: initial output)" + }, + "ab3a2bae-ea7f-41ca-9493-93d8debcee27": { + "name": "y_start", + "value": "0.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Initial or guess value of output (= state)" + }, + "e04d9fc5-761c-4b3d-bd7b-93da9896bd35": { + "name": "T", + "value": "0.01", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Time constants (T>0 required; T=0 is ideal derivative block)" + }, + "29669830-2738-4692-8255-115d1546619c": { + "name": "x_start", + "value": "0.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Initial or guess value of state\"" + } + }, + "implementation": { + "implementation_type": "equation", + "declarations": [ + "parameter Boolean zeroGain = abs(k) < Modelica.Constants.eps;", + "Real x(start=x_start);" + ], + "initial_equations": [ + "if initType == 2 then", + "\tder(x) = 0;", + "elseif initType == 3 then", + "\tx = x_start;", + "elseif initType >= 4 then", + "\tif zeroGain then", + "\t\tx = u;", + "\telse", + "\t\ty = y_start;", + "\tend if;", + "end if;" + ], + "equations": [ + "der(x) = if zeroGain then 0 else (u-x)/T;", + "y = if zeroGain then 0 else (k/T)*(u-x);" + ] + } + }, + "927326ff-f73a-4f98-b7fd-aaf97989121e": { "name": "pulse", "interface": { "ports": { - "69974610-3bd6-42c1-a8bb-afe6086ef0f3": { + "4f9f6b29-36f0-460e-9fd9-d4bdf0ea3c80": { "port_type": "signal", "name": "y", "direction": "output", @@ -629,7 +837,7 @@ } }, "parameters": { - "8e128f7a-cf97-4f27-a664-488921819395": { + "b6460a26-0b89-49e0-8ec7-99281a66668b": { "name": "amplitude", "value": "1.0", "value_type": "real", @@ -641,7 +849,7 @@ "unit": null, "description": null }, - "5ff17831-f2f9-4833-9e4b-4facf7c28c0c": { + "983d6c65-8e81-4f49-9faa-d024e8e6a9d3": { "name": "width", "value": "50.0", "value_type": "real", @@ -653,7 +861,7 @@ "unit": null, "description": "Pulse width in %" }, - "b036592b-4dc0-498b-a966-7146cf9cf95b": { + "c9ccd873-f83a-4b08-8827-4a88a06d502a": { "name": "offset", "value": "0", "value_type": "real", @@ -665,7 +873,7 @@ "unit": null, "description": null }, - "df82150d-973d-4a8a-9bd5-6ce21a2e8bbb": { + "9cd46f38-6ddb-4d69-946d-df96e9f30078": { "name": "period", "value": "1.0", "value_type": "real", @@ -677,7 +885,7 @@ "unit": null, "description": null }, - "1195c93e-3309-494e-9a56-96e8fd5e330c": { + "4e1d29eb-48c8-479a-8968-8f57fd95ea9f": { "name": "nperiod", "value": "-1", "value_type": "int", @@ -689,7 +897,7 @@ "unit": null, "description": "Number of periods (<0 means infinite)" }, - "3f5f7b5c-2794-4e75-a5c8-1ef2b0ecd87d": { + "68102f0d-b58b-4c10-af0d-dffae406fb07": { "name": "startTime", "value": "0.0", "value_type": "real", @@ -723,11 +931,11 @@ ] } }, - "ec0599cb-b57b-4194-b166-526d5cd5382a": { + "ac215607-dd97-4142-b42f-f23502e51cfa": { "name": "plusminus", "interface": { "ports": { - "23a8b215-7c2f-4ccf-b020-4d274d7a228f": { + "baadcb27-0807-4366-819f-7396a1ed205d": { "port_type": "signal", "name": "plus", "direction": "input", @@ -741,7 +949,7 @@ "quantity": null, "unit": null }, - "4c4771f8-22c8-4829-a060-913470e2b2fc": { + "53cc98de-2e05-49ea-af26-e26d5dcd99e7": { "port_type": "signal", "name": "minus", "direction": "input", @@ -755,7 +963,7 @@ "quantity": null, "unit": null }, - "a186a0e1-7aea-4fb6-871c-0ba70d78b8ac": { + "0574652d-df52-4798-8072-da0a26eee873": { "port_type": "signal", "name": "y", "direction": "output", @@ -835,15 +1043,25 @@ "source": "a2709c23-7485-43b2-bc87-28a0f4c37c0b", "target": "8bd1cc42-03b9-4c46-b31b-c1f46933dc9e" }, - "42f8d11c-8b31-476a-8e56-dd1ac3703508": { + "122eaf51-7baf-457f-a944-f946b6ab321a": { "connection_type": "signal", - "source": "69974610-3bd6-42c1-a8bb-afe6086ef0f3", - "target": "23a8b215-7c2f-4ccf-b020-4d274d7a228f" + "source": "4f9f6b29-36f0-460e-9fd9-d4bdf0ea3c80", + "target": "baadcb27-0807-4366-819f-7396a1ed205d" }, - "81e6b0d7-65f2-4edc-a303-6eee78ac1972": { + "8b1c34a9-03c2-4bb5-81ab-7fa66bcafe8e": { "connection_type": "signal", "source": "5807dc83-d049-4f0f-9708-d041a752fcd0", - "target": "4c4771f8-22c8-4829-a060-913470e2b2fc" + "target": "53cc98de-2e05-49ea-af26-e26d5dcd99e7" + }, + "3c659d9e-8218-48ac-9a3d-4297fba275c2": { + "connection_type": "signal", + "source": "0574652d-df52-4798-8072-da0a26eee873", + "target": "8d4b0f2c-2e13-4cfd-a8da-6c03ade4fbbf" + }, + "9b5ab2d7-d302-48e8-98dc-0d60ba54ca01": { + "connection_type": "signal", + "source": "0574652d-df52-4798-8072-da0a26eee873", + "target": "f18c8fd2-dda5-4e8d-a5a7-a22a38abb091" } } } @@ -1333,9 +1551,9 @@ ] } }, - "ac5011ce-4659-4bdd-900a-63a1c779652c": { + "404e82f4-19e2-44ad-b409-3f021e483a39": { "shapes": { - "7a9115fe-4e40-49d9-ab50-2f399ca1038b": { + "29dafe3d-8392-49c0-98ee-80e941b32a52": { "layer": 0, "type": "rectangle", "pos": [ @@ -1350,7 +1568,95 @@ "line_color": "#00007fff", "fill_color": "#ebebebff" }, - "80e150dc-07d3-4d27-9fe0-eecb82fb2689": { + "d24da1c9-bae9-41e6-aee4-79e5f238ea72": { + "layer": 1, + "type": "text", + "pos": [ + -15, + -47 + ], + "width": 31.0, + "height": 81.0, + "color": "#00007fff", + "bold": false, + "italic": false, + "size": 72.0, + "text": "∫" + } + }, + "port_positions": { + "441a44d0-bbe5-49f0-bb0e-5bc891218aca": [ + 7, + -7 + ], + "8d4b0f2c-2e13-4cfd-a8da-6c03ade4fbbf": [ + -22, + -7 + ] + } + }, + "2b0156f6-9977-4735-88dd-14f5eb3d87b5": { + "shapes": { + "702ce5c1-a37a-4f89-8261-0be4b433ae79": { + "layer": 0, + "type": "rectangle", + "pos": [ + -48, + -48 + ], + "width": 96.0, + "height": 96.0, + "line_type": "solid", + "line_thickness": 5.0, + "corner_radius": 8.0, + "line_color": "#00007fff", + "fill_color": "#ebebebff" + }, + "32ccfd50-07dd-4524-be5d-e96a3136d520": { + "layer": 1, + "type": "text", + "pos": [ + -40, + -31 + ], + "width": 78.0, + "height": 62.0, + "color": "#00007fff", + "bold": false, + "italic": false, + "size": 38.0, + "text": "d/dt" + } + }, + "port_positions": { + "d61847c9-0628-4e86-ad91-9390f92f926d": [ + 7, + -7 + ], + "f18c8fd2-dda5-4e8d-a5a7-a22a38abb091": [ + -22, + -7 + ] + } + }, + "927326ff-f73a-4f98-b7fd-aaf97989121e": { + "shapes": { + "89e11690-3faa-482b-a054-10a2c7565c43": { + "layer": 0, + "type": "rectangle", + "pos": [ + -48, + -48 + ], + "width": 96.0, + "height": 96.0, + "line_type": "solid", + "line_thickness": 5.0, + "corner_radius": 8.0, + "line_color": "#00007fff", + "fill_color": "#ebebebff" + }, + "de170a5b-c959-4d6d-afda-fa867dfbca51": { "layer": 4, "type": "line", "pos": [ @@ -1365,7 +1671,7 @@ "line_thickness": 3.0, "line_color": "#000000ff" }, - "66c7fb78-2866-4f9f-9df2-95cfa4c57a2d": { + "94fc1429-0ef2-403b-aa5d-7edcb7b3ebf2": { "layer": 2, "type": "line", "pos": [ @@ -1380,7 +1686,7 @@ "line_thickness": 3.0, "line_color": "#000000ff" }, - "b1b1f425-eea6-48e7-a61d-f9eb221984b7": { + "0d82b577-e3fb-4434-adfb-883e1918e30f": { "layer": 3, "type": "line", "pos": [ @@ -1388,79 +1694,138 @@ 14 ], "end": [ - 3, + -14, 14 ], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff" }, - "0c0b7ec3-557d-4fb4-b8b8-b6b6a269ec55": { + "531dcbea-2c2d-4ed9-bcbc-dfc9b0145527": { "layer": 5, "type": "line", "pos": [ - 3, + -13, 14 ], "end": [ - 3, + -13, + -12 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" + }, + "c4476a1b-7216-4eab-aad6-69ecb5af4b79": { + "layer": 6, + "type": "line", + "pos": [ + -13, + -13 + ], + "end": [ + 4, -13 ], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff" }, - "e5c99d8a-69db-4915-b1e7-84ac5e52de02": { - "layer": 6, + "2b69a1e2-0f80-4a1a-b31c-1f32b91bc46a": { + "layer": 7, "type": "line", "pos": [ - 3, + 4, -13 ], "end": [ - 32, + 4, + 14 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" + }, + "7fac6744-2d5b-49a4-bfca-27cc8889a5b6": { + "layer": 8, + "type": "line", + "pos": [ + 4, + 14 + ], + "end": [ + 21, + 14 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" + }, + "81390b87-3648-4556-a439-9456232781df": { + "layer": 9, + "type": "line", + "pos": [ + 21, + 14 + ], + "end": [ + 21, -13 ], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff" + }, + "c3c9eddf-f4c7-4f76-8bc5-7c313fc9ea72": { + "layer": 10, + "type": "line", + "pos": [ + 21, + -14 + ], + "end": [ + 32, + -14 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" } }, "port_positions": { - "69974610-3bd6-42c1-a8bb-afe6086ef0f3": [ + "4f9f6b29-36f0-460e-9fd9-d4bdf0ea3c80": [ -8, -8 ] } }, - "ec0599cb-b57b-4194-b166-526d5cd5382a": { + "ac215607-dd97-4142-b42f-f23502e51cfa": { "shapes": { - "ecbbad0e-67e3-4d13-ba25-7660f50bc38e": { - "layer": 0, - "type": "rectangle", + "9336da30-7335-4eea-a8a2-ae69d8cad9e1": { + "layer": 1, + "type": "ellipse", "pos": [ - -48, - -48 + -36, + -36 ], - "width": 96.0, - "height": 96.0, + "width": 72.0, + "height": 72.0, "line_type": "solid", - "line_thickness": 5.0, - "corner_radius": 8.0, + "line_thickness": 8.0, "line_color": "#00007fff", "fill_color": "#ebebebff" } }, "port_positions": { - "23a8b215-7c2f-4ccf-b020-4d274d7a228f": [ + "baadcb27-0807-4366-819f-7396a1ed205d": [ -24, 9 ], - "4c4771f8-22c8-4829-a060-913470e2b2fc": [ + "53cc98de-2e05-49ea-af26-e26d5dcd99e7": [ -24, -26 ], - "a186a0e1-7aea-4fb6-871c-0ba70d78b8ac": [ + "0574652d-df52-4798-8072-da0a26eee873": [ 11, -8 ] @@ -1557,6 +1922,30 @@ "ec0599cb-b57b-4194-b166-526d5cd5382a": [ -120, 656 + ], + "83dd8220-554c-4907-b713-c37e1f600f0d": [ + -96, + 648 + ], + "404e82f4-19e2-44ad-b409-3f021e483a39": [ + 228, + 640 + ], + "2b0156f6-9977-4735-88dd-14f5eb3d87b5": [ + 232, + 828 + ], + "927326ff-f73a-4f98-b7fd-aaf97989121e": [ + -428, + 552 + ], + "ac215607-dd97-4142-b42f-f23502e51cfa": [ + -96, + 636 + ], + "b3e059a3-4517-435d-aa63-ddfbbf6fa071": [ + 488, + 552 ] }, "component_labels": { @@ -1667,6 +2056,17 @@ "dassl_tolerance": 1e-06 } } + }, + "port_metadata_database": { + "format_version": 1, + "ports": { + "53cc98de-2e05-49ea-af26-e26d5dcd99e7": { + "connection_annotation": "-" + }, + "baadcb27-0807-4366-819f-7396a1ed205d": { + "connection_annotation": "+" + } + } } } }