From 76bd313f798c5b3f0f3462dbc5e6e979d64a4930 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Sun, 19 Jul 2026 21:26:27 +0200 Subject: [PATCH] Basic document handling --- BEdit/.vscode/tasks.json | 17 +- BEdit/README.md | 77 +- BEdit/pyproject.toml | 4 +- BEdit/resources/icons/configure.png | Bin 0 -> 1450 bytes .../resources/icons/media-playback-start.png | Bin 0 -> 1177 bytes BEdit/resources/icons/transform-rotate.png | Bin 0 -> 1692 bytes BEdit/resources/resources.qrc | 1 + BEdit/resources/resources_rc.py | 1089 +++++++++++++++++ BEdit/src/bedit/app.py | 2 +- BEdit/src/bedit/component_options_dialog.py | 31 + BEdit/src/bedit/data/libraries/example.json | 52 + .../src/bedit/data/libraries/test.bedit.json | 351 ++++++ BEdit/src/bedit/document/__init__.py | 13 + BEdit/src/bedit/document/commands.py | 262 ++++ BEdit/src/bedit/document/controller.py | 673 ++++++++++ BEdit/src/bedit/document/model.py | 329 +++++ BEdit/src/bedit/document/serializer.py | 23 + BEdit/src/bedit/item_options_dialog.py | 42 + BEdit/src/bedit/library/__init__.py | 3 + BEdit/src/bedit/library/repository.py | 56 + BEdit/src/bedit/library/tree_model.py | 91 ++ BEdit/src/bedit/main_window.py | 445 ++++++- BEdit/src/bedit/resources_rc.py | 131 +- BEdit/src/bedit/settings_dialog.py | 62 +- .../src/bedit/ui_component_options_dialog.py | 125 ++ BEdit/src/bedit/ui_main_window.py | 235 +++- BEdit/src/bedit/ui_settings_dialog.py | 51 +- BEdit/src/bedit/workspace/__init__.py | 4 + BEdit/src/bedit/workspace/view.py | 547 +++++++++ BEdit/ui/component_options_dialog.ui | 32 + BEdit/ui/main_window.ui | 117 +- BEdit/ui/settings_dialog.ui | 45 + 32 files changed, 4843 insertions(+), 67 deletions(-) create mode 100644 BEdit/resources/icons/configure.png create mode 100644 BEdit/resources/icons/media-playback-start.png create mode 100644 BEdit/resources/icons/transform-rotate.png create mode 100644 BEdit/resources/resources_rc.py create mode 100644 BEdit/src/bedit/component_options_dialog.py create mode 100644 BEdit/src/bedit/data/libraries/example.json create mode 100644 BEdit/src/bedit/data/libraries/test.bedit.json create mode 100644 BEdit/src/bedit/document/__init__.py create mode 100644 BEdit/src/bedit/document/commands.py create mode 100644 BEdit/src/bedit/document/controller.py create mode 100644 BEdit/src/bedit/document/model.py create mode 100644 BEdit/src/bedit/document/serializer.py create mode 100644 BEdit/src/bedit/item_options_dialog.py create mode 100644 BEdit/src/bedit/library/__init__.py create mode 100644 BEdit/src/bedit/library/repository.py create mode 100644 BEdit/src/bedit/library/tree_model.py create mode 100644 BEdit/src/bedit/ui_component_options_dialog.py create mode 100644 BEdit/src/bedit/workspace/__init__.py create mode 100644 BEdit/src/bedit/workspace/view.py create mode 100644 BEdit/ui/component_options_dialog.ui diff --git a/BEdit/.vscode/tasks.json b/BEdit/.vscode/tasks.json index 5cdaa1b..82b7b37 100644 --- a/BEdit/.vscode/tasks.json +++ b/BEdit/.vscode/tasks.json @@ -98,13 +98,28 @@ "panel": "shared" } }, + { + "label": "Qt: Compile Component Options UI to Python", + "type": "shell", + "command": "pyside6-uic", + "args": [ + "--from-imports", + "${workspaceFolder}/ui/component_options_dialog.ui", + "-o", + "${workspaceFolder}/src/bedit/ui_component_options_dialog.py" + ], + "options": {"cwd": "${workspaceFolder}"}, + "problemMatcher": [], + "presentation": {"reveal": "silent", "panel": "shared"} + }, { "label": "Qt: Build Designer Files", "dependsOrder": "sequence", "dependsOn": [ "Qt: Compile Resources to Python", "Qt: Compile UI to Python", - "Qt: Compile Settings UI to Python" + "Qt: Compile Settings UI to Python", + "Qt: Compile Component Options UI to Python" ], "problemMatcher": [], "group": { diff --git a/BEdit/README.md b/BEdit/README.md index aa62d5a..a815fb0 100644 --- a/BEdit/README.md +++ b/BEdit/README.md @@ -66,7 +66,7 @@ pyside6-uic ui/main_window.ui -o src/bedit/ui_main_window.py Do not hand-edit the generated Python file; change the `.ui` file and regenerate it. Add behavior and signal connections in `main_window.py`. Widget names from -Designer are available there through `self.ui`, such as `self.ui.editor`. +Designer are available there through `self.ui`, such as `self.ui.graphView`. In VS Code, the same commands are available through **Terminal → Run Task**: @@ -87,6 +87,81 @@ In VS Code, the same commands are available through **Terminal → Run Task**: 6. Add icons through a Qt resource file (`.qrc`) so packaging is reliable. 7. Test on Windows regularly; fonts, scaling, and native dialogs vary by platform. +## Graph and library prototype + +Documents and libraries use the same recursive format: a library is simply a +BEdit document used as a copy source. The built-in example defines A, B, and C. + +- A document can own multiple independent top-level graph or text components. + Right-click **Current Document** to create one, and double-click a current + component in the tree to activate it. +- Drag a component from Libraries onto the workspace. Placement recursively + copies it with new IDs, leaving no link to the source. +- Drag components to move them; movement participates in undo and redo. +- Components, interface terminals, and connections are selectable. Use a rubber + band or Ctrl-click for multiple selection, Delete to remove items, and the + standard Cut/Copy/Paste shortcuts to duplicate selected component groups. +- Click an output port and then an input port to create a connection. +- Double-click a graph component to open its owned subgraph; use **Up** to return. +- Graph components show **Pointer**, **Input**, and **Output** tools. Select an + interface tool and click the canvas to add a visible internal terminal and a + corresponding external block port. Interface terminals can be moved afterward. +- Right-click a component on the canvas or in Current Document to edit its name, + icon shape, icon text, fill color, and border color. The same dialog can hide + that component's contained subtree from the Libraries tree. +- Double-click a text component to edit its input list, output list, and + `implementation.source` JSON. +- Right-click any graph component under Current Document to add nested graph or + text blocks. Any current-document component can also be deleted there. +- The active document hierarchy has its own Document panel; the Libraries panel + contains only configured external libraries. +- Select one or more blocks and press `Ctrl+R`, or use the Transform toolbar, to + rotate them clockwise by 90 degrees. Rotation is saved and supports undo/redo. + **Apply JSON** updates that source and participates in undo/redo. +- File → Save writes the complete recursive document to JSON. +- File → Close Document removes the active document and returns to an empty + workspace. An open graph uses a light gray, 32-unit dotted canvas. +- Edit → Settings → Libraries accepts document files or folders of JSON files. + +Every component owns its ports, declarative icon, properties, and child graph: + +```json +{ + "format": "bedit-document", + "version": 1, + "roots": [{ + "id": "my-component", + "name": "My Component", + "position": {"x": 0, "y": 0}, + "interface": { + "inputs": [{"id": "in", "name": "Input"}], + "outputs": [{"id": "out", "name": "Output"}] + }, + "icon": { + "shape": "rectangle", + "fill": "#dbeafe", + "border": "#245c9c", + "text": "Component" + }, + "properties": {}, + "implementation": { + "kind": "text", + "source": { + "equations": ["out = gain * in"], + "parameters": {"gain": 1.0} + } + } + }] +} +``` + +Graph components use `"implementation": {"kind": "graph", "graph": ...}`; +text components use `"implementation": {"kind": "text", "source": ...}` and +never own a graph. Supported icon shapes are currently `rectangle` and +`ellipse`. The recursive model is under `src/bedit/document/`, library loading +and the live Current Document tree are under `src/bedit/library/`, and graphics +are isolated under `src/bedit/workspace/`. + ## Optional tools You do not need another GUI framework. Useful additions are: diff --git a/BEdit/pyproject.toml b/BEdit/pyproject.toml index ff845ed..dda8867 100644 --- a/BEdit/pyproject.toml +++ b/BEdit/pyproject.toml @@ -24,7 +24,9 @@ bedit = "bedit.app:main" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +bedit = ["data/libraries/*.json"] + [tool.ruff] line-length = 100 target-version = "py310" - diff --git a/BEdit/resources/icons/configure.png b/BEdit/resources/icons/configure.png new file mode 100644 index 0000000000000000000000000000000000000000..c774740a1a9f79c285b64d5877b436eb7c5b9f1c GIT binary patch literal 1450 zcmV;b1y%ZqP)Px#32;bRa{vGvuK)lWuK`{fksJU300(qQO+^RU0R#gnB}U_{RR91024YJ`L;(K) z{{a7>y{D4^00k;ZL_t(|+Pzj=P*YbFHGrjpXsewfsEB}|VijdHfFd$Qr$PWjcmxQ+ zf)EHIs1U`dA><*3hXjLfZEcmP6cxnURl#T?ceAt$U!s@;$pZ(13 zo>@QVt}|<&ea>Bbk6|NRU1wM;m4&gFzPeP`c(w8I9+qUJ$%)2V5>^{>vdepVOnCL`6^339 znG;ZJRKo%O{+-s=)|JHZWq52QFtKXiUQ^faU3mHOB?boi@v!XyXuX`^L9iuoSS$bh z1@q?1w{OGKXPtP_`@&p>*4DeQ8)vtC%>NPmsK{jbj%~Pp{Th|tL{<0$y_85^exBq% zPR?1nEP!Lt`_IYUPD!wv%|D~GtfZgz zgjh^{>NIDU3k~&y-!)x@qELa=yl@C&YNJ@e@B^7#u0p5#zQCDAQPT+3q9Viq#(9+TjtyVKcdo~lsT14Gt#`N)Z z_2+Lt?d-(qGiNY&fe(zQj0lZfgSdo5w6{MtpSq+}hkRN78yg#&YT7o%LbmHVuJ-H~ zbuG=!==ik*-E`;t=oqA?OCU^0FjwFni61?7FN}|I*BdGx zg{%yLqmv_?oSiUj`ZQGN_dq7gzebb~SQ8f|CCt;66hB?LY9-v<-56nW1+vNY)N0jz zqWH7cX2r>(B`USj6vhh!m&=9ELLYc~c_J=84rS%KE_!f0qS>&a&DoTR^9~6FhsbL^DHwU@7xyZ`OLP}~1MB*gu)@fdQ&YqK2HIlP$Rgi`rkpVk>v#b$EnE=*QKRGN5kd! zKQ#U2;^LA=>vpWT94|aHyj)*0GTJ(=2xA4&{90#ky1i8uBNh6xTV;E69ZL1iK{vOV zM@Rp4)`LS>Fn8`8V|XO5{lk#pmI)Ik?4&hk^EZ~`9}ByUXh+R&FaQ7m07*qoM6N<$ Ef*xGEQvd(} literal 0 HcmV?d00001 diff --git a/BEdit/resources/icons/media-playback-start.png b/BEdit/resources/icons/media-playback-start.png new file mode 100644 index 0000000000000000000000000000000000000000..71906857cf42f082c70e96737cd9f80ec35bd43f GIT binary patch literal 1177 zcmV;K1ZMk*P)kdg00002b3#c}2nbc| zMg#x=010qNS#tmY19kua19ky@)q>0b000?uMObuGZ)S9NVRB^vcXxL#X>MzCV_|S* zE^l&Yo9;Xs000CSNkl%Nv2I}kqk^*6{QX0LZL=0)zW5F zG%W~zP+W)@OF>cx&K0dd`VED0c(U%)kWw4u_Y$9)GLP|r__>~#*6vz_Yb_d6P zTV?QWUzqXbIty%JAKU3Ahch6MiYCj%fIgz!k-)(o%EjIHUPbxCO52VAb z4Bjq<8_mKJMP6lltMZsjJSzdVg3+`rYAkSCyN68GJ$$kfK|0bb)TuK_S@LYl;06=? zL1e_n2Ft5U(if?VW+5Gc_)gA$x{_Pq85OWu@{9swiGE~>U-*lM$O5hnKpyyAac<|= zry{>UEjIMg&lY0C8Fgw@`GI5n-04TlBP*WLPc>T6?~l?$DGA%`AD6L^2nQs{K>W=q zK4PJ5kr7!6JV-)HdMNV!GT;FM%i1Qk)vz3vjc<92pD+s%lhNK~2EIS?B{c~fQAMN# z3o-+t93-!ac|PD{7BK_MbE{M`C}n)flhR}9cg}}DNvK=%cw%eeYuw}rkK-(I3z8#) zfM_KL9fMok%H~?+Aw}=MU z6dl82xDrhyVKO4eH_XuxRtiA|C6059-c|C{Zsb3FAw@)(oJb-FqoEoDQ!G$NCYIvU zoMaEH#X4Km`GkN9sD@F9;PVROu^ex4l_hEZ!kQZZFC=H4)7-GR>@-xT zIz!bgG}c48X}v$hUWLW2t8Mj*?>L9EOE3Assnug0;N?E_uy{+fn_WE0{oIG+U*@?k zmaHdbInCiSYabS~u%|5)>`&J22q%#ZI^pbTJ5yihaec`i*HG+SE@xZV<9c?T$F*aU r3kMi-{h{@|yGoelg1dY+Va@sv%NF!G!=KTL00000NkvXXu0mjfS=|gw literal 0 HcmV?d00001 diff --git a/BEdit/resources/icons/transform-rotate.png b/BEdit/resources/icons/transform-rotate.png new file mode 100644 index 0000000000000000000000000000000000000000..cfbe823cecc5c128764348c616ad56551fca6810 GIT binary patch literal 1692 zcmV;N24ne&P)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L00!&;00!&<9(6c100007bV*G`2iXP@ z7ZW7fg)*xE00taML_t(|+U=Enj1|=x#-I1h+&i;(KmDLo*di+=DDu^6!mb}Atx5mT zxJoE7C7K8&))g(bjkd8}NDEk^u2n*;p{dbG8x*8QpvWqk3c6HWOo+*nAc)k0TH)S1 zcki9=^Y+ZS!(=x~8r-yh@k`$O&Y8KB=Q;0t&KaENJZBgfcn@P`>(;F}R|q56x9`1I zJ7AG5|0qvti~@!U~-eV~#7)Kg6(1Mq43I zos)qEJC|c-jM)G)w1|}QD`%fK2|Eu7T=|U}Cbmi(FPhkNz{0mD2@?2v2#n?{OK%v4 z&PPuP)Z7r?Y>E&>P$nXDPpjdyti!f$gE-8T+z?@4$>Yna%vlT!8cz-YyiQEh*dT+K+u14z~ z?GM7f*P=(4-(cb4zj{*?HF@hO1I@PR9CvleaeR}vz8}-arv=Qk{C)ia=1;F8 zh_$d5%}i(!q);}L%qk>Ew?^RTzoFizy--wq3@Q_oGXq!ry7WL(9s?~l8BI3P_*TM$ zF9jeW@b-rh0^dVNn-3}Zi~}Gu!C6Qt5yvW22B!(YeNfX;M$sjn*Gn*(Uz9$H(5o z5hgU&uxNG#Oag}9l7{{e2}6xZjnivW-6xUc`dETTm z(m+a!Ed~n>ITK*s`=P`&lLI!*WQKr9A~BI2iNFp?eH0=5P8z8n%sW8rm=I7n!? zy9khYh{@=7*0-*J;$MAjAdKMtO)g(ZpC7-NK68G5V9Jb}r^i@!y$?n@QzOYn)_?Pi z5hEEJy8L;A@XSDrCtipDgPme zezRh`^Y?S%yZg_Le>kdF02o%dim9s#*a6K*mh9%p6qL0jdNw~PG9G~ z*Q4&o`h&qob_KlRuR%^%=brKmEG`XoKUw7CCrh$icDU#ab7i>7)tbki7x2(0zBHB= zOBgGU_t%X)y+=;U+S60i+3uicons/document-save-as.png icons/document-open.png icons/document-new.png + icons/transform-rotate.png diff --git a/BEdit/resources/resources_rc.py b/BEdit/resources/resources_rc.py new file mode 100644 index 0000000..915731e --- /dev/null +++ b/BEdit/resources/resources_rc.py @@ -0,0 +1,1089 @@ +# Resource object code (Python 3) +# Created by: object code +# Created by: The Resource Compiler for Qt version 6.11.1 +# WARNING! All changes made in this file will be lost! + +from PySide6 import QtCore + +qt_resource_data = b"\ +\x00\x00\x05\x82\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\ +\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\ +\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\ +tware\x00www.inksca\ +pe.org\x9b\xee<\x1a\x00\x00\x04\xffID\ +ATx\xda\xbd\x95[hTW\x14\x86\xbf=s\xe6\ +\x96I\x1ac\x1c\x93\x0cTB\xd3H\xcc\x93\xd8V\xa5\ +\x90\xa0\x14)\x81B\x85<\x14\xdf\xa4\x17D)\xd8\x22\ +\xda\x82\xa4\x08\x8d\xad\xc6K\x0cDQ\xdbZ\xa4\x88\xd0\ +\x17\xa1O-A\xbcTm!\x16\xa57\x94\x18\x9dz\ +\xc9\xe4b.\x93\xb9\x9e9gfw\xdc8\x19=\x99\ +L\x0e\xa1\xf4\x83\xc5\xde\xfbp\x98\xff_k\xeduF\ +\x93R\xf2_\x22z\xc52*\xd8\x8dA\x0b&\x958\ +)Cc\x9c$\x17\xa8\xa2Kn\x92\x03P@\x83\x02\ +B\x88z\xa0\x9e\x85\xe0Gc7\xbb(g]km\ +\xabk\xc3\x8b\x1bh\xa8h\x00\x01\x83\xd3\x83\x8b\xfa\x1e\ +\xf45\x5c\x1e\xbc\xfc\x9e8,~\xe0W\xda\xe5\xf72\ +c1\xa0\xa8\xd7u\xfdB*\xa5\x83\x00\x81\xb0\xac\xca\ +%j\xa5\xb0\xcfd3\xb4\xfd\xd4F\x7f\xbc\x9f\xed\xaf\ +n\xa7\xe9\x85&\xf5\xecq\xf21\x08\xa8\xad\xa8e\xf3\ +\x8a\xcd\xac\xad[+z\xfe\xecy[_\xa7_\x10'\ +\xc4\x1br\x8b44,<\x11\x8f\xc6b8\x1c\x02!\ +\x8a\x04\xc0\xd3}\x9e\x0f\xae\xbe\xcf\xd5\xc4U65m\ +\xc2\xeb\xf5\xf2\xc0\x081=6\xce\xc1\xd7z\x01\xf8\xf4\ +\xb7\x0f\xa9\x0aTS\xe6\xf7\xd2\xbe\xbc\x9d\xb3\x7f\x9fm\ +\x91\x11yF\x08\xf1\xce,\x03B@A\xdc\xf1dU\ +g o\xa2\xb0\x07nEns\xee\xfe9\x82\xcb\x82\ +h\xe5\x1a\xe1t\x88\x0a\x0d&\xa3\xa3\xe4\x99\x8a\x0e\xe2\ +\xa9\x8e\x12\x03|e\x1a\xc1\xba \x8f\x22\x8f6\xd2A\ +\x83\x86\x95\xa7\xc2\xb3*\x00`\xd9\x03\x1c\xf9\xbd\x1b\xe9\ +\x95\xf8\x17\xfby\xa8\x87X\xe2\x06\xb7\x13\xe2\x89q\xf2\ +$\x93\x0f\xc8\x92@\x97\x10\x05\xca]~X\x84\x0b\xc9\ +\xde\xd9\x15@\x14/?\x80\xe5l\x98&\x17\xc3\x17!\ +\x08\x03c\x03<\x0e\xffC\xd0[F\xb5K\x90\x0c\x1b\ +\xf0\x16\x8a\xd0\xad\xfbD\xa2a&\x0dIXO0R\ +\x9b\x06\x09dx\xbdX\x0b\xe6\xea\xbdZ)\x9c\xc9d\ +2D\x1dQH\x83\xbc#\x19\xdf\x95\xa2\x18\x97>\x9e\ +\xe6Y^8$\xe0%`\x02\xff\x1c-\x10\xf3\x9b\x00\ +\x90\x12Mj\x90\x05\x04\xb6q\x0a\xc0\x05\xf8\xf1\x16i\ +\x01\xf6\x0c\xa8\xcb\xe9\xc0\xe3\xf3\x80\x0fh\x84\xea\xe3\x82\ +\x80\x1b\x02\x1e0\x87\xe1\x97\x1d\x12\x807{\x04\x15A\ +\x18I\xc3\x98\x0e\xce& \x098I,\xa4\x02*x\ +j`\x85g\x05c\xc6\x98\xcaH\xbc\x0c~?,\xf5\ +\x81\xe1g\x86\x9a\x06X\xbc\x1cd\x02\x12I\xb87\x85\ +z\x9fQn.\xa8\x02\xf9\xd04\x8d\xad\x8b\xb6r-\ +u\x0d3k\x12\x89\xc1\x84\x04_\x06\xb4,3L\x00\ +\xf1\x08\x8c\xe8\xf0(\x06i\x03\x00\xc9%N\xda\xad@\ +Qc\x1e\x8f\x87\x96\xe6\x16V^Y\xc9\xf5\xaa\xeb\x98\ +\x09\x18\x9e\x02\xd2P\x96a\x86Q\x132S0\xa4\xc3\ +h\x0a\xf0\x02w\xe8\xe7G~.R\x01k\x94\xaeJ\ +ee%\x9d\xd5\x9dl\x8bn\xe3n\xc5]R)\xb8\ +?\x09~\x01ug\x04\x0e\xc0t\xc2T\x12\xd2\x1e\xa0\ +\x12x\xc8 ;\xf9\x04\x98\xd0\x98\x85\x12\xb3\x15\x00n\ +\xb7\x9b\xd5\xaf\xac\xa6\xfbJ7{&\xf7pc\xc9\x0d\ +\xb2\x1e\x88N\xe4\xc2\x098\xf2\xb3\x07\x1a\x1a\xe6Ms\ +\x80\x0ev`r\x1dH\x17m\x81\x0a\x1b\xe2\x80\xba\x88\ +\xe5\xe5\xe5\xb4\xb6\xb4r\xe8\xc6!\xfa\xfe\xe8\xe3|\xe0\ +<\xb7kn\xa3gu\x5cN\x17n\xd3M\xfdh=\ +\x1bc\x1b\xe9\xd8\xd9q\x0c\xb8\x04\xc4e\x8e\x92\x97\x90\ +\x12\xe2\xc5L\xacY\xb3\x86\xc6\xc6F\xda\xee\xb5\x11\x0a\ +\x85\x88\xc7\xe3\xaaB>\x9f\x8f\xba`\x1d\xcd\xcd\xcdt\ +l\xe9\xf8\x0b\x88\xca\x1c\x00\xc5[P\x88Y&,<\ +kH\xfd\x13\xd6\xd6\xd6\x12\x08\x04X\xb5j\xd5s\xef\ +8\x9dN\x5c.\x17\x80\x91\x17/n\xc0\x9a9\x94\x12\ +\xb5>S\xd5\xc8\x89\xa9\xcc\xed0\xe7\x14(\x8a\x97\xbe\ +\xa4\x09+*\xd9\xc2\x0b\xf3\x18\xb0\x0a\xc3|\xa2\x16\xf1\ +\x82h6\x9b}n\x05\x10\x0e\xa7=\x03\xc22\x8e\xb3\ +(a,\x93\xc9(\xd1t:\x8da\x18O\xce*\x00\ +\xe2i3\x06$\xedV\xc0\xb6h\x9e\x9c\x90\x12\xd5u\ +]M@$\x12\xc9\xad\x09\xf5{\x15\x95U\xc6\x97\xfb\ +;{\x80\xfe\x92\x06\x94\xf8\x1c\x82%PY\x9b\xa6I\ +*\x95\x22\x1c\x1efdd\x18\x8f\xc7\xcb\xd2\x9a\xa58\ +\x1dN\xd9\xb9o\xdf\xf1\xd3_\x9f\xfaLJ\x99-e\ +\xa0\xa8x\xe9\x16\x14\x0c\xe42WY\x0f\x0d\x0d\xb1~\ +\xfd:2\x19S\x99\xd9{\xa0\xeb\xf47'\x8e\x7f\xa4\ +\xc4m]B\x98w\xe6\xad\xd9\xe7\xcb?>>\x81\xdb\ +\xed\x99\x11?\xd0s\xe4\xdc\xc9\xde\xa3\xef\xe6g\xdf\xf6\ +\x14\xd8G\xddzU\xfeX,\xc6\xf4\xf44\xc1`p\ +F\xbc\xf7pw\xbbE\xdc^\x05\xb0\xd7\x0aK\x0b\xd2\ +d\x01\x97\xdb%;\xbb\xf6\x9f\xfe\xea\xe8\xb1b\x99\xdb\ +\x1fC\x9b\xe6T\x05r\xa1\xca\x1e\xa8\xa93?\xdf\xb7\ +\xff\xd8\xb7\xaa\xe7yq\xfb\x06\xd4gTM\x82\xa5\xe7\ +\xa5>:\xc2\xe1\x00\x87\x86\xe1p\xc7\x0ev}q\xe4\ +\xbbS\xea\xb6\xcb\x85|\x8aC9\x03\xebY8\x09\xa0\ +\xdf\x22n\xd7\x80\xca&\x04\x84\xf8\x1f\xf9\x17g\x93$\ +jH\x9d\xf5+\x00\x00\x00\x00IEND\xaeB`\ +\x82\ +\x00\x00\x08h\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\ +\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\ +\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\ +tware\x00www.inksca\ +pe.org\x9b\xee<\x1a\x00\x00\x07\xe5ID\ +ATx\xda\xc5WkpU\xd5\x15\xfe\xce\xf3\xbeo\ +nn\x12\x12C\x12\x02I\x0cv4\x12E\xb4Z\xf1\ +1\xe8L[\x19\xabV[0\xb5\xc2\x80\xb4\xc5JC\ +\xcb\x80\x19m;@\xcb mGej\x09-M\x7f\ +t\x1c*\xb6\xe3\xa8\xa5*L\xb5\x9a\x0c\x0de\x1aZ\ +0$\x12%\x0fBH\xcc\xe3>rO\xee=\xe7\x9e\ +\xb3\xbb\xf6\xb9w\x0e&\xb96\xf8\xcb5\xb3\xb2Ov\ +\xce\xde\xdf\xb7\xd6\xfa\xd6\xd9;2c\x0c\x9f\xa7\xc9\x98\ +n\x029\xfb<\x09\xb0\xcd\x8d\x8dG\x98\xc5V k\ +\xcbnY\x8eE5\x8b\xa1H\xe2,j\x82\x90\xe1,\ +\x8a\x02\xff\x09\xf0\xd1\xb2 \xc8\x12\xf8\x0c\xff\xbbH\x0e\ +\xf2t\xda\xc4\xfb\xa7\xff\x8b\x7f\xb6\xbe\xeb\xac\x0f\x87\xc3\ +Gg\x12\xc0\x946\xb5\xe2\xd4\xe9S(..\xc1\xbc\ +\xa2\x22\x14\xcc\x9b\x87\xf2\xf2\x05p\xa9\x0arY\x86C\ +f\x8c\x1f:\x88\xf1\xdf\xed\x83Z]\x83\xa2\xef>\x0e\ +\xcf\x92\xfa,\x09\x01ZJ\xc7\xc7#\xc3H&\x93\xce\ +\xda\xb1\xb1\xb1\x15\xb3\x08X\x14\x81\xcf\xe7\x83\x22\xcb\xf6\ +\xb3\x08fG\xd8?\x12\xc9\x05\xeeX\xb4\xff(\x16L\ +\xfc\x15RA\x01\x94\xbe^t\xddu\x07\x02?j\x82\ +\xf4\xc8z\xfb\xc5\xb2\xc2\x00\x0d\x8c\xef9\x0d+'\x81\ +P^\x08.\x97\xcbyY\x92\x04\xd4\xcc\xcf\xcf\xd4h\ +&8E\x17\xb9x\x12\xe5\xd6\x16\xf8nRq\xb6\xbd\ +\x0a\x0b;\xcfS\xc9$L\xec\xfc1\xbeP\x7f5\xc2\ +\xf7\xdc\x83\xa9\xa4\x0e\xb2\xb9\x09\xf0\xae\x08\x04\x02\xce\xb3\ +@\x00\x12e@\xa5\xba\xe62-:\x00w\xcf7\x10\ +\xf4{\xb1\xff5\x15\xcfv\xf4a-\xad\xb95\x1eG\ +BU\xf1\xc1\x0f6\xe1\x96\x95+a1\x02\x84\xc5\xf7\ +\x9c\x86\x95+\x033\xd2\xcc\xc0\xf5'\xcb\x02f\xa6@\ +OFa\x9c\xfa\x1a\x0aB.\xbcu\x9c\xe1\xb9C\x06\ +\x22\x91\x08\x9aFGq\xf0\xdeJ\xc4\xdf\x1c\xc2d\x22\ +\x01\x83\x04\x986M0kz\x09L\xcb\x9c\x9b\x80\xc5\ +\x887-<\xd3;:=S\x96\x01\xffG\xabP\x91\ +o\xe0\xc4\x19\x03\x9b\xf7\x0a\x98\x9c\x9c\xb4\x09l\xdf\xb8\ +\x10\xd7\xdc8\x80\xe4\xad\x95\x88\xd5\xb5\xa0\xab\x7f\x0c%\ +a\x1f\x01\xce(\x01M\xccM\xc0\x02\x0cz\xb1\xbc8\ +\xc8\xcb\xed\xd8`\xdbz\xcc\x0f\xeb\xe8\x1b4\xb0a\x8f\ +\x80\x84\x96\xb2\xc1\xd7\xdeW\x82\xfb\x09<\x10\xca\x07\xab\ +{\x0e\x15\x05\xd5TR?R\xba\x09\xd34/O\x03\ +\x9f43m!\xcd]\xba\xb4\xf0\xc3c\xbbPW\xd0\ +\x8b\x89\x98\x81\xd5\xdb\xd3\x88k\x82\x0d~\xdbu><\ +\xber\x14yy^\x8c\x17\xed\x02\x5c\xb5\x10E\x89\xd2\ +\xcf\xc8-\x98\x8c}v\x0d\xa4\xa9N\x06oG\xc3\x04\ +\xb7\x81\xd3/\xe2\xeap\x1bRi7\xd6\xec\xfc\x18c\ +1\x89\xc0\xc7QY\xc2\xb0k}\x0a\xe1\xa0\x88a\xff\ +\x0f\x91\x94o@@\x94\x01r\xdd\xc8h\xc0\xb4r\xb4\ +\xe1\xdc\x1a`\xb6\x88.\x8eNB\xd4\xbap\x95\xf7U\ +0\xa5\x10\x9b\x9e\xe9\xc6\xd9\x01\x13qR\xbbKJ`\ +\xdf\x16\x0f\x0a\x82)\xf4\xe3!\x9c\x8b\x7f\x09~AG\ +\x8a<\xae'\xc0-?\xe8\xa2l^N\x09f\x100\ +-\xcbN\x9fj\xf4\xa2J\xd8\x0f\xd5W\x89\x9f\xef\xeb\ +@\xdbI\xcd\xfe\xaa\xe9\xc9\x18\x0e\xee\x0c\xa2\xac \x86\ +\x88r'\xce\xa3\x81\xb2\x10\xa0\xba\x07\x91\x97\x1fr:\ +\x87\xf6\xb0\x01\xd94\x11\xe6\xea\x82Y\x1a`H\xc4\x87\ +\xb0\xd0<\x00ox1\x0e\xbd\xd6\x81\x13=\xf9\xb4\xd9\ +0b\xb1\x08\x9a\xb7\x06\xb1\xb84\x02M\xadCGb\ +\x1d\xfc\x01\x09\xa2$\xc3\xed\xf3\x92\xe82}\xcf2\x05\ +\xe7\x1a\x98\xb6\xbfu9\x1aho{\x0b7\x86G\x11\ +Z\xb4\x14\xed\xed\xff\xc2\xcf\xf6\x7f\x08]\xcf\xf6{\x83\ +\x84/\xd6N a\x15\xe3\xd7o\xd4\x83\xc9\xc7\xe0\xf1\ +x\xed\xe8U\x97;\x13z\x06\x1b\x1e\xaf\x17}=]\ +\x9f\xad\x04\x02\x18\xbe~\xfd)T\x5c\xd9\x80\xee\xce\x0e\ +\xbc\xf4\x8f\x10\x07\xb7\xeb\xfe\xe0r\x0d\xdf\xbc\x93\x94\x0d\ +\x1f\x9a\x0e\x14c\x22q\x06\x8a\xa2\xc2\xedv\xc3\xedr\ +\x81\xe5P\xfc,\xac\xb9D\xb8\xea\xf6Q,\xbb\xe3\x09\ +\x8cE\x93\xf8\xceO\xdb08\x14\xb5\xeb~\xd3\x95Q\ +lk\xb0\xe0\xa2(\x1b\x9b\xe7\xe3\xe2\xb8D\xe0\x1c \ +s\xf2\x19\x86\x81\xcb1\xd3\xfa?\x1a\xb8\xa1z\x18\x0f\ +|\xebI\xc4\xa3CxxC3\x81k\xf6\x87\xa4\xb2\ +p\x02{62\x04\xfd.4\xb5\x5c\x81\x9e!7\x81\ +\xf3;\x81\x08\x97\x9b\x22w\xa2\x9d\xdb,\x8b\xe5.A\ +Yx\x04[\xb7l\xb4#Y\xd7\xb8\x0f\x1f\x9d\x9f\x02\ +\xb7\x802\x8e\x17\x1a\xd3\x98\x17V\xf0\xcb\x97\x0bq\xbc\ +\xcbC\xe0\x80\x08\x01^\x8f\x07\x02\xe3\xe0\xd6\x1c\xb0s\ +\x94 \xe4\x19\xc3\x8eG\x87 \xb8+\xb1y\xd3f\xfc\ +\xa7;\x03.ZQ\x1cx\x92\xa1\xa2TA{\xdfR\ +\x08%\xcb\xb1\xba!`\x9f\x9c555\xfcv3+\ +\xf2\xf1\xf1q4m\xdb\xc6\xb3\xc3\xa3\xb5[\xba\xb8\xa8\ +\xe8\xd3E\xf8\xfb\xa7\xdd\xb5\xcf<\xb1\x1c\xf9K\xfe\x82\ +\xedO}\x1fG\xdb\xe3\xd9V\xd4(r\x035\x15\x16\ +:\x87\xaa\xf0\xc6\xfb\xf5\xf0\xfbY\xe6\xee\x10\x0aAU\ +U$\x12\x09~tswDG\x17\x1b[\x94\x92$\ +\x81sK\xa5RH\xa7\xd3\x0e\x1e#w\x08\xbc\xbe\xf7\ +\x11\xd7\x8a\xa5\xfe\xbf\x15V\xde\x85gw\xacBkg\ +%\x8f\x01\x8c\x84\xf2\xd4\xea\x09,\xbbJ\xc2\xf9\x89\x12\ +l\xd8\x9d\x84\xc9\xde\x84\x22+\xf6M\xe9\xf9\xbd{y\ +\x8460c\x19R|t\x0e\x1e\x9b\x94\xc8\xf3\xcd\xdf\ +\xe7\xf3\xb9/\xa5\xa1\x92\xaa\x87'\xf3\x1b+\xffx\xe0\ +W\xf8\xed\xeby\xf4b?\xb8\xad\xbd{\x18\xf7\xdf&\ +\x22\x92\xccC\xf3\xdb7#\x16\x7f\x07\xb2$\x83\xa9\x16\ +\xdf\xcci7\x8a\x8c\x00\xed9\xc7\xf9|V\x95\xc8\x0e\ +\x9fN\xc0\x1b*Y\x1f,]\x22\x84ki\x8a\xb5\x80\ +\xdb\x97\xebG\xf0\xbd\xfb\x04$\x0d\x15\xbb_\xa9'P\ +/\xc0\xec\xa0l\xd1M%\x93\x1c\x94\xa7\x96\x8f\x5c\xb4\ +\x1c`Z\x168\xaaC\x94\x13\xa3\xc5\x1eZ\xeb\xf3\xfb\ +1A\x1a\x91\xb3\xe9\x97\xaa\x97\xdd{\xbd\x16\xe9AE\ +\xb1\x81\xad\x8f]\x83W\x0f\xb7\xe2'kMH\x94\xea\ +5;\x5c\x08\x95\xf9Q\xe4S\x00pp/\x98}H\ +\x19\x98\x9a\x9ar\x00ht2A\xa3\xed:\x91\x92%\ +)3G\x0e\xfe\xae\x00N\xde>!\xe5L\xf4\xc5_\ +\x81\xa8)\x89h/\xb4\xe89\xcc\xcf\x1f\xc5\x1a\xbaX\ +\x0cG\x06\xb0\xbd\x85\xa1{\xc0\x85\x07o\xae@UU\ +\x15\xfe~\xe4\x08\x81\xf1hu\x0e\xea\x08\x8b\xa2wJ\ +\x90\x05\xcf\x90\xd4);\xb6\x083w\x02\x81FY\x94\ +\x10\x8bF\x11\x08\x063\x04N\x9c\x8d\xed\xbe\xce\xd3\x05\ +\xaf4\x82\xd4\xe4\x05$4R\xfb\xb9\x02<\x7fP\xc0\ +X\xd4DYi\x00\xd7\xd6]\x8b`^\x10S)\x1d\ +\x1a)\xde\xccv\x80\xa6i\x0eh6\xfd\xce\xa8\xeb:\ +\x11\xe3\xf3\x96#P1\xdb\x9e\x85\x85\x85\x18\x19\x19\xc9\ +\x10x\xa7\xfd\x83\xc5/\x1f>\x86\xc7\x1e\x98\xcf\x0a\x02\ +\xbe\x7f\xff\xe6\xa5\xf1E\xa3\x91H\xfe'\xfa\xd5I\xf7\ +\x9e_\xec\x01\x99#4\xba\x07\xe6\x12\xa1\xf3l\xe8:\ +oC\x87\x80$\x8a\xf6\xfc\xe0\xe0\xa0\xdd=\xf2\xe6u\ +_}\xf4\xc2\xd0\xb0P[\xee\xee|\xbb\xf5\xe2C/\ +\x1e>9\x9e\x17\x0c~\xbb\xac\xb4\xf4iI\x94\xbc \ +\xd3\x0d\xdd\x06\x92eyZ\x9ai\xb4S\x9fM\xff\xb4\ +C\x87oN\xc6\xeb\xec\xcc9\xc2\xbc\x14\x98.k\x93\ +\x93\xe7\xea\xab\x02\xc5\x7fx\xe5x\x04\x80\x9b=\xe5c^\x87\xaf_\xa3\xc4(\x11\ +\xb2\xff%m2\x81\x7f\x96\x0d~\xf3p\x8e\xaa\xa1\xd8\ +\xdcd\xc8;?*\x81\x1b-^.@\xab\x0d\x81\xa2\ +m\x19\x13\xcb\xc3Q\xb0z\xbe}\xb6\x16de\xcf\x22\ +\x82\x81\x1d\x07\x19]\xdfr\x11K\x80\xa1\x82\xb9\x9e\xb3\ +kF\x18\x13&4KZ<7D\xd7\xa5\xe1\xc2/\ +\x5c\xae\xb6\xd9\xf8\xf9o\xbb\xed\xb5\x15_\xb6\x97\xdd\xd8\ +<\xe5D\xbbS\xf1/\x1b0\x05\xa3\xe4%36\xb5\ +\xb0\xa9\xb2m\x99xa?\xe3\xdaI5P\x22\xc3\xf5\ +\xd0\xf0\xe8\x05L\xa4\x7f\x10\xc5\xc0w\xda\x19?\xfb.\ +c\xca8^\xf5\xc0\x16\xafT0E\x87\xe3\xe1\x1a\xc7\ +\xe3\xeb~\xf2Bp\xf9(\x03M\xcb\x8a\xc0\xb5\x5cX\ +\xf4\xf2\xd4\xae\x9f\xbe\x18s\x83\xa5\xc5\xb5\x89\x13e\x81\ +\xfd\xc7\x02\x5c5\xc1\x83\xcc\xc4\xd2\x0c\xceL\x10\x01`\ +\xca\xae\x19\x04\x10c\xfe,\x89\x9e\xeb\xd1R\xf6y\xfb\ +\x86;\x0aC\x8e\xcb'\xca5y\xeb(\x03\xcd\xb6\xe8\ +\x1cwu\xc9q|\x93\xb7\xbe\x1f\xd3\xc7\xa7\x05\xcc\xb4\ +\xfc}\xfd\x01\xe6O\xaf\x22\x88\xb5\xb0\x16\xcdLh\x11\ +\xa4P6\x94\xcc*$H\xb7F\xe0\xe8\xbf\xa1\xde\xa7\ +\xbdPT|\x0c*\x13\x933\x03W\xfc\xba\xf4@S\ +\x1e\x03-6\x19M6\xd1\xf8V\xc2\x83/\x05 !\ +\xf0\xf6\x87\x11\x02\xdfCG\x9b\x0f)\xf9\x8c\x03 3\ +\xc1YH)\x11\xc7*T\x06\xa7\x0b\xd4\x10\x80 l\ +|7\xa6r\x85\xef\xd6\x06j\x1cW\x8f\xd5\x96\x09\xb0\xf0\xca\x12j~\x8c0\ +Tf\xd2\x08\x93\x88\x22%\x18kQ\xc9\x5c\x9f\xbd\xde\ +\xc2\x89p\xfd@\xcb+\x03\xcf\x0dH\x0c{\xe8\xfb\xe0\ +dxb\xe6\xca\xf2\xec\x92';\x9c\x9a<\x06\xc5\xf7\ +\xfb\x9c\x1cA\xd1\xb9\xc6\x81iRO\xd5\x8d\xd6\x1fX\ +\xd9&\xfe\xb4\xc7\xc7\xde\xc3\x11&4\xd6\xb0hv\x05\ +L\x02\x86\xde\x92\x86\xfe\x10\x09C\x8dI\x8d\xd3kS\ +\xdd'\x91\xe4\xfaX\x90\xd0\xff+\xba\xc4K\x9e\x8f\xbd\ +]\xf77\x16&\xff\xc2yN@\xde\xa1\x1e\x07\xea\xf9\ +6\xd3\xc4\x1c\x83\xd8\x17P\xec\xfbUK\xb2\x0e\x9a/\ +n\xe4\x00\x8aE\x9dy\x1c\xfc$\xc6m3\x87!\x91\ +\x8a\x89D\x10Z\x88\xce\x88\x8b\xfa3\xaaW\xba~_\ +\x9b%4X\x02\x0f\xef\x94Dd,\x81\xc2\xf1\xc5\xbd\ +\x8e\x07\xaax\x9cW-\xf8A\xa5&O\x1e|\xa70\ +C \xa5\xb9A\xb8\x0dy\xc3\x84\xe2\xc1\xcd\x9e\x9ay\ +U\x09@\x8b\x90\xa1\x82(5\xa2\xb2\x00H\x0b\xd7\xc7\ +\x02i\x85\xf4\xfd\xfa;{\x8f0\xef\xff8\x1ezu\ +\xb9\xbd\x09\x8a\xd3O4U\xaa>\xd69.h8\xa0\ +U\xc7\x1fk\x9b7q\x96\xd2@J\x8b-\x06\x0b\x0d\ +9\x13`\xde\xfb\xa1\xc45\x93\x036\xcdd\x86\x80H\ +\x221\x91\x1d\xcf\xa9\x11\x8c0\x92\x1d\xdb\x02\xb6\x05\xf4\ +\xbe\x1c\x12Ic\xe1u\x8f8Hh\xb8\xbb\x08\x90x\ +\x88\xc9\xe8v\xd6\xb7\xae\x80\xe2_}m \x8c\xe0\xfe\ +M._H\x9f\x1f\xbf\x98>s~\x7f`\xca\x8c\x9b\ +\xa7\xc7Xv\xbd\x0fF:S#\xc9F\xd6\x7f\xf3\x8c\ +\x11#\x19\x1b\xba\xfcy\x83\xf0\xcc@\x8c'^\x8f^\ +<\xb4\xa6\xe56\xd4\xc9L\xb8O_p\xf6\x8fQ\xc1\ +6\xef\x1d6\xc7O:\x84\xab\xfe|x\xedE\xb4\xf1\ +}\xabw\xde\xfaf\xec:\x94C\xb3\x9d\xf6W\x97\x18\ +i\xefU\xd4\x17]Z% \x90\x845;B\x84\xd2\ +X<\xf3\x97%\x8c \x15\x1f\x0d\x01\xa3Y\xbd=\xf8\ +\xa3\x04\x96J)\xff!\x88~\xb3b\xbb\xf3\xf7\x82\xc8\ +={\xd98^\xb8nA\xccs\xda\x89B\xd6\x0b\xad\ +.\xac\x0f\xacz\xf9\xd5\x96\xc6\x8f7\x86\xd8vP\xf6\ +\x9e\xeck^\x8dse\xedN_\xe7\xbe\xd7\xa3\xee\xc7\ +\xfa\x83\xc1Gv\x05\xbc\xe2\xafANW\xe7\x9e\xf2\x0c\ +,.\x0du?Y\xe4\xe3'K\xb2\x5cv\xb8\xe8\x0c\ +se\xb8\xca\xd5\x9a\xc7\x9e\x17\xf0\xd0'\xbe4\x96\x16\ +K\x18+\x8f\xf7\xd7\x8d<\xbdGN\xca\xda\xd3S\xd4\ +\xd9\xee\xa9\xfc\x10?*\xd5\x1e\xda\xe2p\xe0)q\xd7\ +\xe5\x9a\xe73s\xc8\xb3W:l\xf7\x94\xbb\xf1\x17\xc6\ +\xff\x1c\xf3.\xe7wm\xf7\x95x\xf3\xbbU\x99\x88o\ +\xd9\xefJ,)\x0e\xe2\x1c!\x8c\x81\x5cO\xe9\x82\xd0\ +\xe3\x8dWw\x187\x1e\xfdB\xc2\x8d1\x15\xa1<\xe2\ +n\xb8\x10\xff\x17\x1a\x96\x9e\xd2\xd9Z\xe6L-\xdc\xe3\ +\x8c\xc7y\xbe\x06\xff\x01\xd8\x02\xdah\xfa;i$\x00\ +\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x07\xe4\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\ +\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\ +\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\ +tware\x00www.inksca\ +pe.org\x9b\xee<\x1a\x00\x00\x07aID\ +ATx\xda\xc5\x97kl\x14\xd7\x15\xc7\xff\xe7\xce\xcc\ +\xee\xec\xae\x1f\xeb\xc7b0&\x92\xb6HEM\xdb|H\xa5VQ\ +\x9b\x84\x846<\x02\xa6\x84\x87\x036`\x9b5\xbbk\ +\xf65\xbb;3\xf7\xde\xd3\x1bwea\x84\x8d\x1b>\ +\xe4'\xfdt\xe6\xcb\xcc\xf9\xdfs\xe6\xc3\x0c13n\ +\x0d\x22\xe0\xe3?d\xd6\x01\xf6\xef\xdfl\xf5\xa5\x06\xee\ +\x8b\xda-\x9b\x1cw\xfez\xd7\xed\xe9\x8e\xc4\x17t\xd8\ +v\x1b\x05\xfe\x95b\xe8]\xcaH\xef\xd2{~yp\ +_\xc6myc\xc3\x86\x7f\xc8Y\x07\x18\x1d\xfd\xc5]\ +~\xf5Lyq\xef\xcf\x86p\x1dG\x8e\xac\x89;U\ +\xf5m0\xedH\xba=\x1d\x89\xb8\x83X2\x0a;\x96\ +\x82\x15o\x07!\x0a\xe5\x87\xd0\x81BP\xae\xc1\x1b\xbf\ +\x82|\xe6\xc0U\x84\xea\xa7\xca\xab\xee^\xf1\xc5\x8cw\ +\xd3\x00\xe7\x07\xb6\x0fF\xb5X\x94+\x1e\xd8\xbdr\xed\ +\xbfw\xa0A\xff\xdb\xab\x1e$\xf0\xee\xb6hj^\xe7\ +\xc2;a\xb9\x0a`\x1f3B\x11\x13\xa6\x03\xc5\x8b\xe7\ +\x90\x1b|k\x8cX~w\xd9\xa6\xe1Wf\x0cp\xec\ +\xf0\xbd\xc5\xa5=\x1b[FG\x8e\xa2\x223O\xadX\ +w\xf8\xb1\xe3\xef\xae~\xd2\xd5\xf6\xf7\xe7u-F\xb4\ +5\x09\x80\xf1\xff! \xab\xcd\x18;}\x00\xc5\xec\xd0\ +\xb3w\xbb\xa3\x8fb\x13\xab\x1b\x068~h]1=\ +o}\x8b\xd6\xc0\xe5\xcc\x19x\xe1\xd8\x99\xa4\xdb\x9b\xee\ +\xec\xec\x06Y5\xdc\x12\x9c\xc0\xc8\xc9S\x188u\xf8\ +o\xb1R\xf6\x81\x0d;\xa7\x86\xb0\x01\x03\x03\xdaW`\ +\xf217\xb5\x0ca\xb04\x1d\x89\x02\xac\xabF`\x12\ +\x86\x81\x1b\x95\x00\xe5\x80\x1c\x0b\xac\xeb3L\xa8\x82\xee\ +t\x1f\x8a\xa5\xda\xc6S\xf9c/\x01\xd8:5@\x03\ +\xa5\xd4\xe4\x0d\x96\x00TH\x00Mm\xae\xc3\x00\xb5\xd1\ +\x8b\x08\xc73\x90\xf9,d9\x0b\xb2,D\xda{`\ +\xb7\xce\x81\xdb\xb5\x08\x91\x8e6\xe0\xbaI3\xaa\xe8[\ +~\x07\x06\x07\xc7\x1ez\xe1\xd1\x96\x13_\xdb]zf\ +\xea\x0a\xfe\xb5\xb6\xb8\xa8yU\x0bC\x81D\xa3\xab)\ +\xdc\xc8\xc0\xc6`|\x04\x85s\xefA\xd7\xab@\xa8A\ +F\x0e\x19\xac\x196K\xa0A\xacg9\x9a\xd2kA\ +\xf6\x0dfQ\x92x\xe1\x97\x7f.\xcf\x8d\x89\xdb\xbe\xb1\ +\xa7P\xb8f\x05\x0c%%\x88\x18\x9a\xc9T\x00D\x13\ +\x01\x98%*#\xef\xa3\x9e?\x0fX\x80o\xdb81\ +b\xe1|\x86\x90)\xa0Z\xab+P\x18\xc6\xbb\x13\x0a\ +\x9fI\x87\xe8\x1a>\x09?;\x84\x96\xe5\x1b\xe1\xb46\ +\xe3Z\x12.!\xdd\x97n>r\xf4\xe4^\x00\x0f\xc2\ + \xd0\x08\xa0\x95\x842\xb2\x0a\xa1\xb5\x91C\xb0\xb1<\ +v\x14~m\x08\x14\x17\xb8\x5c\x14x\xf5\x90[\x1f\x1c\ +\x8f\xee&\xc7M\xef\xd9\x97O\xfc\xeaO\x85D\xcf\xdc\ +\xc4\xa7J\x22\xf6\xdc\xee7]\xff\x9f\xe7l\xa8\xba\x87\ +R\xff_!=\x1f\xda\x0f\xa6x\xdf\xda^\x5c\xcc\xf2\ +gw}\x99\xdc\xc9\x09\x10\xf3\xc4~\xc9&0\xd1\xe4\ +\xfb\x14V/#\x0c.\x98\xe6\x04\xdfc\xf4\x8f4\x15\ +\xbb{\xac\xf5\x0f?~\xe1\x03\x5c\xc3\x8f^\xbcr\x1c\ +\xc0\xb7\x9e\xdf\x91\xda54\xee\xbc\xd1\x9e\xab-_\x91\ +\xaa\x8b\xca\xc0Aj\xea[\xc7`-&Wd\x01w\ +,\x99\x1b\x09j\x99/\x00xQ\xc0P\xf75\x94\x0a\ +\x1a\x9a\xd4\x1c\x80\x8d\xb0\x08vS\x0b\x84\xcb\x88\xa5\x18\ +K\x17\xaa\xd6dLo\xc74l{&\x9b_\xbe\xc8\ +\xfdt\xd1I\x5c\xf0\x94U\x0c\xaf^\xbcT\xcf\x9c\xcb\ +\xe9\xc0/\x1bC\xa36\xaa\xbb\xd3\xed\xba\xea\xf3\xe7'\ +WP\xae(\xf2F\xfb\x11\x14\x86\xa1\x83:\xb4\x09\xa2\ +u\x00\x11mB\x22\xb5\x0a\x89\x8e;\xe1\xb44\xa1\xef\ +\x9e*\x96t\xab\xaf\xbc\xf3\xbb\x85?\xc64l\xd9\x99\ +\xf1z{\x22\xdb\xcav\xec \x80sAvp\xd04\ +\x1d5\xe6\x8d%c}A\xca\xad\x14=^<\x19\xa0\ +V\x95\xc3~9\x83j\xee$\xca\xa3o\xa1\x9a5{\ +/_\x80\xf2\xc7\xa1\x82\x02\xd8&D\x9a\x17!\xd6\xd6\ +\x85\xee5\x15\xb4\xb6\xe8\x1f|\xf8\xf6\xfd}\x98\x86y\ +I\xbck\xbbN\x06\xc0\x98\xaa\x153\xca\xaf\x8f\xea\xff\ +9f\xcc\xc6,])y\xcc\x93\xef\x80_\xd7[\x8e\ +x]\xaf\xfa\xe3ja\xe8+H\xc9\xd0*\x07B\x96\ +-bv,\x13\xc0\x01\xc5\x1c\x12n\xa4\x95;\xe7\xa4\ +\x8a\xa9V\x9e\x07`\x007\xa0oK\xa6<\xfa\x5c\xe7\ +\x98dJ\xda\xa4k\xca\xbb*\x85\xe3\x06\x00<\xa3\x9b\ +\xb09Z\xf2\xb4\xbfk\x17\x09\x1b\x86\xcfm=}\x0c\ +\xc0\x12L\xc3\xcb?\xe9\xc1\x97\x1e\x1b\xc6\xec!\xb0j\ +/\x13&\x88\xc9JF\x0a\xcb\x0d\x98U\x8d\xb5t\xa5\ +T\x81\xd4\x88\xee\xdc\xc9\xda\xc6\x0c\xfcp[\x17I\xc9\ +B\x10h\xcf\xae\x1e\xf9\xcd\x9d\xb3\x0b\xf1\xc1oS\xe6\ +\x90\xb2f\x11k\x00u\xe5\xe5\x02\x05\x04\x00\x5ccx\ +\xa5,\xa2\xf1\x88h\xac\xe0:\xf6~'I1H4\ +[\x92\xee\xefP\x94\x8c\x83\x0e\xe7\xdbD\xbe\x18\x0a\x00\ +\x1a\xb3@\x91\xb5 \x1aA\x05\x92j`v\x01\x04\x0d\ +\xa3\xc6\xb0P\xb3#\xf1\x88\xf2a\x10\xb8\x8e\xaf?]\ +\xe0\x87\x9e\xae\xf0]\x1d>\xf7$\x99\x131\x81\x95\xf3\ +|\x1a+Z\xd1\x9f\xef\xea\xc6\xcd8\xb1o\xbe\xa3-\ +\xd1\xdbl\xeb\x12;v\x15B\x14\x00\x14\x8dW\x8d\xc3\ +0u g\x17:\x9a\xe9\xc3F\x80\x1b\xd3\xf7=f\ +\xb6-\xc0\xb6\xa85\xae\xc5\xbdK,kx\x8c\xdd\x97\ +\x9f\xec\xc2t\x1c\xda\xdf#\x02\xc7Z\x16A\xb4N\xba\ +^1\xf7\x96\xcc3\xaa\xb0D\x09\xc0\xe5F\x90\xe2\xe9\ +\x8c]hk\xa6C0\xd8\x98\x86\xb3\xcfF\x89-\x1b\ +0!\xd8\x12zEwU\x89h\x92\x8f\x0f\xd7\xad\xbd\ +Ot\xeb\xf6\x16\x877?r\x010\xfc\xfd7\xb7\x91\ +\xeb\x88\x88\x9b\x88\xa5\x1c\xaf\x02\x11\xd4=s_\xc8\x80\ +C@`*\x13\xc3\x81\xd6\xceU\xdfu\xf2\x9e\xb4V\ +/\x0cO\xcd\xf8Qz\xf6\xf98\xb1m\x1b-\xc0\xfa\ +(\x84\xc5\xb0\x04\xaa\x1c\xa7\x13\xc3U(\xa7](\xd4\ +ES\x9cDg2f%P\xb2\xdb\xa31\x17\xd5B\ ++\x94\x8a\x93R\x0eI\xe5@*ej\x84\xa4\x8cB\ +Jz\xed\xec\x1c;s%\x9fy\xea/\xf2\xfd\x19'\ +`\x1a\x22\xfdpI\x9f\xfeu'-\xdd\x9a\xe3\xc97\ +\xfc\x8f\x0bq\xcf\xed. \xea\x9a\xac\xa8\xa9\x168\xf0\ +\x00&\xb0\xaa\x86\xb0-\x9f\x88\xc0 \x07\x0cE\x0c\x9b\ +\x99C\xb0\x08\xf3\xf56\xfb\xfce\x8fV\xceW\xfd3\ +~\x96\x1f\x7fi\x11\xb9\xfa*\xd2_-2\xa6\xe1?\ +\xaf\xdfN \x12`\x10\x98\x05i-\xa0t\x84\x94\x8a\ +A\xa9(I\xa9!\x95E\xa1\xb4\xcc\xb5\xa3\xa4\x15y\ +\xbd?j\x85\xe5\xf1\xcc\xe3\xbf\x0f30L;\x813\ +%\x81\xcd\xdbM\xf3\x19`\xc7\x06\x884\x18\x02\xcc\x1a\ +Z\x03BI\x10B\x02\x87lFH\x9a\x05,a\x81\ +\x1dy&\x97\x94\xa2>\xecM6\x9f2\x81\x8fI\xff\ +\x9b+\x09\x131\x98\xa0\x14\x9191\xa4\x12$\xe5G\ +'\x17\x13\xc2\x16cy\xdb\x1e85\xa2Zsa~\ +\xd3\x1f\x18\xb7\x1a`j\x88wV\x13\xb4\x86iN0\ +^\xd3\x9c\x22N\x8c\xbclU\x8c\x9c\xbf\x22\x1fx\xc2\ +\xaf\x03<\xcd\xaf\xd9-r\xfc\xe0\x1a\xa2p\xe2\xd3\xcc\ +\xa8D\xcc\x8e\x8a\xe0\xe2%\x8aH\xd6\x15\xcf\xd3+\xb7\ +\xfbz\x96\xff\x86\xb7\xce\xd0ki\x92\xd9\x1cI\xdfg\ +[y\xdc\xfb\x08\xdf\xfc\xdf\xf0\x93D\xe0\x13\xe6\xbf\x92\ +r\x1f*A9\x0fr\x00\x00\x00\x00IEND\xae\ +B`\x82\ +\x00\x00\x03|\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x04\x00\x00\x00\xd9s\xb2\x7f\ +\x00\x00\x00\x02sBIT\x08\x08U\xecF\x04\x00\x00\ +\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\x01}\ +\xd5\x82\xcc\x00\x00\x00\x19tEXtSoftw\ +are\x00www.inkscape\ +.org\x9b\xee<\x1a\x00\x00\x02\xfbIDAT\ +x\xda\xa5\xd4YH\x94a\x14\x06\xe03\x95\x06\x0af\ +:\x83(\x819Y\x9a\x9a{\xa9\x98\xbbcY!\x99\ +\xa3\x95\xedA\xa1FV$\x96\x8a\xde\xa4\xb9TDF\ +Z\x11ZD\xab1\x04I\xbb\x95\xa6\x92E\xa1\x91]\ +\xe8hi\x13\xb9\xa4\xe8\xec\xcb?\xf3\x86\x17\x8a3\x8e\ +\xb3\x10\xef\xd5{\xf8\xbe\xe7\xff8\x17?A/\xda\xb0\ +\xf4\x85\xa0\xf9C,M\x84\xc1D\xbf\xde\xaa{z\xdf\ +\x14\xf0\xa0\xac\xf1\xb5I\xc03ux\xac0n\xbe\xeb\ +\xbb=DCIy&\x01r~\xfe\xb1\xeb+\xd9\x1a\ +}>\xb5\xbe\xe9\x12\x92\x97I\x00\x94yF\xa5\xbdW\ +i\x0c\xb8~H\xaa(\xbeM,3\x00\xad\xe9\x11\x8d\ +\x8cn\xe2\x1a\xceC\x96\x08\xfbF\xa5K7\x80\xcc\x01\ +\x8b\xaa\x04J\xb4\xb7\x18~\xe9\xd9=\x15\x1a\x9a\xc9\xd1\ +,\x00\xe2\xa6\x8d)\xe5\xaa\x9a\xac\xd9\xb3\xf2\xc4\x89\x09\ +\x99.\xb9\x00d\x01@\x9c\xc6\x0f\x0a\x0c\x0c89\xcc\ +Ll:;\x95\xf8\xd2G\xab-\x02@\x19eb\xa8\ +\xf1\xf2\xd1to\xa8R1\x12\x94\xdc\xa5\x05\x16\x02\xe4\ +\xfdI(\x83DR\x920\xd5\xb2\xbd\x86\x86\x14\xf81\ +\xee\x14\x0f\xb2\x14\xa0\xfd\xd5\x93P\xa1\xbb\x9bl\x89\xd5\ +\xfaV\x0d1\xca\x05\xb4\xd8b\x00D\x81\x1f\x85Rh\ +\xb4\x0d\x17n\xe4\xc8\x952\xf4\x8d;n\x02Y\x03,\ +\xd8{e\x02J\x8c\x8d\xbd\x14\xa90\x89\xb3\x02\xb2\xb7\ +\x0a\x00Qx\x87P\x02\x0d\x9e\xa0\x17\xc2q\x87\xad \ +k\x01\x9b]W\xc7!\x87\x14\xb5(\x15\x90\xa3\xd5\x00\ +\x88\xc2\xbeH;\xf0\x1d\xedZ\x874\x90\xd5\x80\xbd\x7f\ +^\xd3;]3~B\x8b\xeb\xcdn\xdb\xad\x04\xc86\ +\xac\xb9\x09j4bj\x95\xddL\xc2or\xb4\x0a\xf0\ +\x89<\xa0\xe9@\x8f\xb8\xa2\xe2b\xee\xb7\xc1_8\x8e\ +\x80R\xb2\x06\x88\xef?\x8c:\xac)\x00\x81\xbc}j\ +\xb5iH\x95\xaet\xb0\x18\x088v\x0cIp\xed\x9f\ +\xee.\xcfbq\x02\xe1/\xcc\x02\xfe\xcb\x83\xae\x85>\ +\x0a*\xdc!\xe3\xc3[\xcdI\x9c\x01\xec\x97M&\xe3\ +\x08\x13p9\xa4>\xf8ap\xe4<@D`\x8a\xa4\ +\x08\xa7q\x12Y\x88\x05\xa7m\xf61v\xe5Z\xec\xc4\ +)\xe4\xa3\x18{4Q\xd9F\x80\x88\x90-\xd2\x14\xac\ +\x17\xf3t;\xb0\x11\xee2\xb6\x9b\xfeS]D<\xa4\ +\x83\x8f\xe5\x8a8]\x86&\xea\xe8\x1c \xaa\x97\x0f\x8f\ +\xf7\xc4\xf2\x1dLA8\xd8\xf5 \xfd8o\xf3a6\ +c\x9d\xdc\xcd\x8e{.\x81I\x92\x1a\x00\xc4\x8a\x96\x85\ +\x0eO\xb5\xa0a\x1e\x5cF\x8d\xfd:8\x9fc\x11\xc9\ +x\xb1A\xfe\xed)\x08\x0c\xd7\x03\xbc\xf9\xa9\x08h\x02\ +\xf9\x1d\x8c\xd1\xf9\xea\xd89\xa0\xb9a{\xba+x\xf0\ +}\x08\x0a*\xca\x80\xdfM=\x80\x9b\x9f\x89\xe4\xbfq\ +\x8f\xf9j\x1e\x9cZ@\xc6\xe3T\x15\xcc\xec\xd3E\xb7\ +E\xf7\xe6\x82\xfbJ\x0fp\xb3[\xa5\xb8\x84j\xf0\xe1\ +lp\xdd\x90\x88d\xce\xa3\x061\xcc\x8am\x06K\xe4\ +n\xe4\x0e\xb8\x8e\xb0\xef\x80L\x87S\xe0\x22\xe2\xfe\xf1\ +(\xd6[\xe2\xff\xe4\x1f\x0bb\x1dd\xf9kV\xc6\x00\ +\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x03\x5c\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x03\x00\x00\x00D\xa4\x8a\xc6\ +\x00\x00\x00\x03sBIT\x08\x08\x08\xdb\xe1O\xe0\x00\ +\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\x01\ +}\xd5\x82\xcc\x00\x00\x00\x19tEXtSoft\ +ware\x00www.inkscap\ +e.org\x9b\xee<\x1a\x00\x00\x012PLT\ +E\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00CEE\x00\ +\x00\x00MQO_bbcfeZ^\x5cgl\ +jjnk\xb8\xbc\xbd\xdb\xdd\xde\xc2\xc3\xc3\xcf\xd0\xd0\ +\x8d\x90\x8d\x8e\x90\x90\x92\x96\x94\x99\x9b\x9b\x99\x9c\x9a\xa0\ +\xa1\xa1\xa5\xa6\xa6\xa9\xa9\xa9\xaa\xab\xab\xab\xab\xab\xad\xad\ +\xad\xae\xae\xae\xaf\xaf\xaf\xb0\xb0\xb0\xb2\xb2\xb2\xb6\xb6\xb6\ +\xb9\xb9\xb9\xba\xbe\xbf\xbb\xbb\xbb\xc1\xc1\xc1\xc2\xc2\xc2\xc3\ +\xc4\xc3\xc8\xc8\xc8\xcb\xcb\xcb\xcd\xcd\xcd\xce\xce\xce\xcf\xd0\ +\xd0\xd6\xd6\xd6\xd8\xd8\xd8\xdb\xdb\xdb\xdb\xdd\xde\xdc\xdc\xdb\ +\xe1\xe1\xe1\xe2\xe2\xe2\xe3\xe3\xe3\xe4\xe4\xe4\xe5\xe5\xe5\xe7\ +\xe8\xe8\xe8\xe9\xe9\xe9\xe9\xe9\xe9\xea\xea\xe9\xea\xeb\xea\xeb\ +\xeb\xeb\xeb\xeb\xeb\xec\xec\xec\xec\xec\xec\xed\xed\xed\xed\xed\ +\xed\xee\xee\xee\xee\xee\xee\xef\xef\xee\xf0\xf1\xef\xef\xef\xef\ +\xf0\xf0\xf0\xf0\xf0\xf0\xf1\xf1\xf1\xf1\xf1\xf1\xf1\xf2\xf1\xf2\ +\xf2\xf2\xf2\xf2\xf2\xf3\xf3\xf3\xf3\xf3\xf3\xf4\xf4\xf4\xf4\xf4\ +\xf4\xf5\xf5\xf5\xf5\xf5\xf5\xf6\xf6\xf6\xf6\xf6\xf6\xf7\xf7\xf7\ +\xf7\xf7\xf7\xf8\xf8\xf8\xf8\xf8\xf9\xf9\xf8\xf9\xf9\xf9\xfa\xfa\ +\xf9\xfa\xfa\xfa\xfa\xfb\xfb\xfb\xfb\xfb\xfc\xfc\xfc\xfd\xfd\xfd\ +\xfe\xfe\xfe\x08\xd9\xcd\xca\x00\x00\x00\x15tRNS\x00\ +\x01\x02\x03%;EKLx\x81\x92\x98\xa6\xae\xb6\xc4\ +\xf7\xfa\xfe\xfe\xd1\x91\xf4\x89\x00\x00\x01{IDAT\ +x\xda\x8d\x93]K\x02Q\x10\x86g\xe6\x08\x09u\x1b\ +\xfd\x97$\xba\x0d,\xba\xe87\xf6\x93B\xcd\x0b#J\ +$#\xfcHM={N3g\xc6Y\xb3\x8b\x9a]\ +\xc4\xe5yxyg\xd9\x03\x7f\x0dB# \xfc\x9a\x5c\ +E\xfb\xd7\x80p7\xa3\x80<,\xeb/O\xf3\xbe\x16\ +`\xf6\x81\x81\x0d\x22*\x1ai0\xb8\xc0\x94\xca _\ +\x9a\x80\x19\xb1\x16@y`L\x92P\x18Q\xd3\xaa\xb0\ +\x80\x14\x04\x8b\x81<%\x9fN[VE\x84\x10\x0ag\ +L\xbb\x0eT-\xac\x0a\x0b\xc4\x5c0\x11\x81\x5cr#\ +\x06\xad\x22\x020\xb7\x96\xc2u0\x94*\x9a\xc0\x5cn\ +\xc5\xaaH\x02\xba\x80\xc6I\xa9d\x17`\x82\xedi\x01\ +\xa8\x06\xf8SY\x13kn\x0aZ\x96\x09\xc6\x1d[\xba\ +%h\x84\xf2\xda@\xad\xe2%a\x9fg\xd9\xa2\x18*\ +0#\x0bD\xa6)-\x13\x1d[\x95z\x1b\xb2\xec\xb4\ +\x1d\xcfc\xc2\xa8\x1d=\xc1\x03\xd2j8\x899\xe0\xf8\ +\x0a]Pn\x01\xd5ht\x91co\xd0&\xad\x92u\ +\x0b_*M>c\xde\xf6\x06\xd7\xb4\xab\xa2\x82/\x96\ +g1\xaf\xbbO\xb7hU\xfa\x9aPO\xda\xe4F\xe7\ +\x99\xb9V\x19n*\x16\xf2\x0f%\x86\xd7\x1b\xdcUY\ +/\x93~\x93\xfe\x8a\xe8\xe4m\xd1\xf6*\x9dE\xda;\ +8t\x96\xc5;j\x01\x07]ZQ\xfc\x81c\x03_\xef\xe8k{\x9c\ +\x91\x04\x13\x1a\xb2\xd4\x0e\xc6\xccDT\x91\x83+J\x09\ +\xf8\xd2\x05\xe2\x08\x99(F\xb6X_4`\x93e9\ +pJ\x9e\xf0\xc3DP\xd1#\x90\xebJ\xcfk\xd2-\ +/\xf6#\x8fl\x9c\xc0\x0a\xf8\xdc\x99\x87z\x87\xba\x0b\ +\xdf3b\x80%\x95\x93\xd6\x88;\x96\x00'B\xc0\x95\ +\x09\x11\xa2\xce\x1d\x84\xe0h\xd2\xb2\x88\xefN\xf3t\x97\xc5\ +\xad\x1eC\x8cYEi\xf5\xe8r6\xaaa\xf4\xc1)\ +yj\xffU\xa9~+LRd\x9aT\xeb\xef\xec\xa9\ +8\xf3\x1d+\xbf\xa3\xaf\xafwfm-\x9e\x8d\x16\xb8\ +\xaa\x84[\xa4\xc8\x12\xaa$H\x8f\xd4\x86\xbe2\x16\xe9\ +\xa4q\x9dH\x10\x91$\x90Lpl\xef\xc5ec^\ +\xf7r\x1b\xedh\xed\xc7\xd1\x1d\xa3r\xc5\x04\x128\xdf\ +\x08\xe6NN\x8dO\x8d\xc4]nNO\xb9:\x98\xa7\ +S\x83JT\x0b\x0c\xc6BU\x952\xd10%,f\ +\xc8\x143\x84\xcd\xf4\x15\xc5\x13\x5c~\x7fYz\x93\xfe\ +\x9d\xdf\x0b\xb2\x87\x8d\xd6\x03\xf7n5\xa6XE\x94\xc3\ +z\x22 \x12A{1\x89H%!\x92\x88U\x12R\ +#\x86\x17w\xbdq\xf7oF\x99\xe7R\xd6\xbe\x0d\x03\ +f\xbe5%\xceWf\x92\x83%mf\xf0\x86\x19\xd2\ +D\xf2\xd9`\x1a^\xd8\xf9\xda\xd2\x06w\xfdj\xd6z\ +\xd4fm\xad\x19-\x04\xc7\x07\x0a\x9b\xe4\xbf+s\xd2\ +$&\x93\xc8\x7f\xb3\xe7\xcc*\xc2w\xf1nHG\x09\ +\xf9\x5c\x8a\xda\xed\x0c\xf6t\xf6\xe1\xf9-'\x97\x16\xdc\ +5\x03\xef$\x18\xdea\xfc\x07;\x00\xdf\x09\xb1\xdb|\ +\x84\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x07\x06\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\ +\x00\x00\x09pHYs\x00\x00\x0d\xd7\x00\x00\x0d\xd7\x01\ +B(\x9bx\x00\x00\x00\x07tIME\x07\xdb\x05\x02\ +\x16\x024\xdd\xedHB\x00\x00\x06\x86IDATX\ +\xc3\xa5\x97]\x88]W\x15\xc7\x7fk\xed}?\x92I\ +2C\xbeD[m XM\x8d\x9a\x07\x05i$\x1a\ +4\x94\xfa \x92\x8e_P_E\xa4\x0f\x82\xe2\x8bJ\ +\x11\x0aA\x11\x8c\xf8$-\xbeY\xc4\x88\x22\x8a\xa8\x89\ +\x11#TI\x03M\x8b\x09\xfd\xc8\x84&\xc1$\x9dd\ +&3w\xe6\xce\xdc{\xcf\xd9{\xf9\xb0\xf7\xb9\xe7\xf4\ +N*)n\xe6r\xce\xd9g\x9d\xbd\xfek\xfd\xffk\ +\xed=Bc\xec\xdd\xbb\xb7377\xb7\x1bh\xf3\xd6\ +\xc3\x1a\xf7\x92\x9f\xe7\x8f\x1c9\xb2z\xf2\xe4I\xfe\xdf\ +q\xc0\xde\xc6\x08ei\xab++v\xf9\xf2\xe5\xab{\ +\xf6\xecy\xe4\xe0\xc1\x83\xfav\x1d\xca\xc4\xf3'\xcd\xec\ +ok\xfd~m \xb5\x89\x99af\x84\x10(\xcb\x92\ +\xe1pH\xaf\xd7cqq\xd1>|\xe0\x80\x1c|\xf8\ +\xe1\xd9m\xd3\xd3\xbf=s\xe6L\xbcW\x00o\x89X\ +DPUD\xa4\xfe5A\x89 \xaa\xf8V\x0b@.\ +^\xbch\xa7N\x9d\xfa\xb5\xf7\xfe\xe8\xa1C\x87\xee9\ +\x13\xfa\xa5\xa7\xfe\xdc|\xeeW\x0e\xc6\x91\x9b\xd5\xa47\ +\x00i\xfe9UZ\xad\x16\x8b\x0b\x0b2=3c?\ +\x7f\xe6\x99\x13\xdb\xb7o?\xba\x7f\xff\xfe{\x02\xa1\xbf\ +\xfc\xee#|\xf1\xa9\xbf\x9e\xfc\xdc\x93\x7f\xb4\x07\x1f\xfd\ +\xe6\xd9\xb1\xc8*\xc7\xcd,Ldh\x0cF\x95\x95\x95\ +\x15\xbc\xf7\xe2\x9c\xb3\x9f\x1e?~b\xffC\xfb\x1e\xbb\ +\x17\x00\x1e`\xf9\xce\xfc\xa7\x1f\x9f}\x94\xdf\xad^\xad\ +u\xd1\xc8\x82d@\x93\xf3\xe3{\x11b~\xbfyj\ +J\xd6\xfa};v\xec\x07\xbf\x0a\xef\xfe\x14\xe5\xd4{\ +P\x84\x80\xe1\x80\xd2@\xc5\x0a)\x06G~s\xec\xb1\ +\xbf{\x80a\x7f\x99\x97^\xefq\xe9z\x7f\xbc0\x0d\ +\x0a\x10\x01U,FD\x04\xcb\xfck\xb6SUbL\ +\xbak\xb7\xdb`&kk}~\xf8\xb5\xc3<\xfd\xa7\ +\xd7\xd8\xb1\xe7\x83\xb4\x9d\x07\x01\x15\xa1\x14\xf3\xff:w\ +\xe1[@\x02\x10F\x03\xe6\x17\xfa`\xc5F1\xaab\ +f\x1b\xca\xa5\x02Z\x09U5Q\xee[-\x10\xc1\x80\ +53\xber\xf8\x01\x9e|\xf69\xfc\xce\xf7\xe1\x9d\x12\ +\x02\xec\x9c\xee\xca\xed\x9b\xff\xd1\xba\x0a\x9c\xa3\x1c\xae3\ +\x1a\x8d6TBu5\xb37q\xae\xaa8\xe7p\xce\ +\xd1j\xb5\xd8\xb2e\xcb\xd8\xd6{O\xbb\xd3a\xf3\xe6\ +)\xba\x9b\xa6\xf8\xce\xec{\xe9];\xcfp\xb0\x8e\x95\ +C\x16W\x07\xac,\xdd\xb4\xb1\x06\xcc\x8c\xf5\xd1\x80\xb2\ +\x1c\xdd\xb5\xf6+\xa7f\xb6a\xce9G\xb7\xdbez\ +z\x1a\x80N\xa7\x03@\xb7\xdb\x85\xad[\xd9\xb1k\x17\ +eq\x1f?\xf9\xfa4_\xfd\xd1_\x98\xb9\xff\x03\xb8\ +h\x14\x83\xf50\x06P\x8e\x0a\xcabH,G\x1bz\ +l\x15urn\x84`\x181kRp\xce\xd3\xedn\ +b\xe7\xae]<\x7f\xee\x1cEQ\x82\x19\xa2\x99\x16K\ +\xba\x98\xde\xb6\x8d_|\xff\xf3|\xe1{'\x98\xb9\xef\ +!\xb0X\x03\x88aD(F\xc4\xb2\xa8\x85\xa7\x8aT\ +@\xa2Q\x84\x92\xa5^\x9f\xe5\xde\x1aEY\x12b\xc4\ +\xcc\x88!P\x86@Y\x94\x94\xc1\x88!U\x84a\x98\ +%0\xb17\xe0\xda\xcdEF\xc5\x88'>\xbb\x8f\xe3\ +\xbf\xbfD\x88\xe6\xc6\x00\x8a\xa2`0Xg\x905\x10\ +,\x22\xc1\x10IX\xa2\xc1\xcd[K\x9c}\xf1\x15.\ +\xbcv\x85\xfe\xda \xe7\xc7\xd2\x9f%Z\xa2\x19X\xca\ +\x8e\x8d\xdf\x19\x16\x0d\x13#\x84\xc8\xee]\xdby\xf9\xfc\ +st;\x9d\xd1\x18\xc0h\xb4F\x7fy\x9eP\x0c\xb9\ +z\xfd\x16\xb7\x17\x97k\xd5\x0b\x94\xa1d\xee\xca\x1b\x9c\ +\xfe\xe7\x8b\xcc>\xfee\xda\xdd.w\xfa%\x22\xff{\ +\x93\xb1\xbb\xcd\x9b\xf0\xb1\xcf\x1c\x05\x98\xbdq\xed\x89\xd3\ +\x1e\xe0\x8d\x1b\xd7Y\xec\xad\xd3[^\xe4g\xcf\xfe\x81\ +~\x7f\x1d\xcd\xf97 \x04\xe3N\xafO\x7fX\xe0|\ +\x97\xb3/-\xe0\xf2;i8s\xf9\x1a\x1b \xe2\x04\ +\x18\xcd\xf7-u\xbc|\xfe\xf9\xc3\xfe\xdbO\x9f>\xfb\ +\x89\x8f\x7f\x84\xb2(\x11\xa0(#&\x96\xd1\xe6\x0f\x05\ +\x14\xc1\x0c.\x5cZ\xc0\x11q\x02!\x82\x0a\x04\x03'\ +PV\xa7\x85L\x9d\x0a\xa8\xd56*\x102\xd0\x02%\ +D\xc3\xcf]\xb9\xfd\xd1w\xed^\xa0_\x94\xe3\x08&\ +\xd1V\xcf\x16\x0d\xa7\xa9*\xca\x98\xa2\x8bc\x0dd\x9b\ +\x1c\xae\x90\x00:I\xed7\x92@di\xa4\x00\x0c<\ +\x06E4\x1c\x96\xd0\xe5h\xaa\xc5[\x0a\x85%\xd4h\ +\x16\x15)\x12\xb1\xdc\xc9$S!P\xc6\xdc\xc5\xabu\ +\xaa,Zm3\xa6\xce\xc0\x1b\xa4\xb2\xc9\x11\x89\xa6\x8f\ +\xa8R\x96\xe7\xadq\x18\xd3\xea^\xeb\xa8\x84D\xb85\ +*\xc7\xe7 $\x7f\x14b\x9a3 \xc4\x08f\xb9\x13\ +\x02\x11K)\x0f\x10$\x19\x16\xb1\xd6\x80\x00-I\xd9\ +\xb0\x9c\xda\x10\x92C\xa1Nm\x15\x9d\xe6\x94\x8f\x05\x18\ +3-\x96S\x9f\xa9\xf1f\x10\xa3!V\x7f \x96\xc5\ +\x22\xf5\x8a\xa3X\x9f\xdfT\x12\xb8\xa6m'sJ\xe5\ +$\x8b-J\xb2\xa9F\x09l\x12(,\xd1\xee\x05C\ +\x0d\x06\xd1\xf09B\x80\xb6\xd6\xe9'\xd4\xe5\x16\xf3\x9e\ +n\x99\xcf\x22\xebd\x10\x19\xef\xf7\xbey|\xce`\xaa\ +J\xf0\xd9V\xc7\xb6\x22\xa8w\xb4%\x1d\x18\x5c3\x8d\ +\xbe\xe67S\xfe\xa6\x9a\xf6\xd4\xce\xc4\xa5\xf7\x15\xc7m\ +`\xd4\xe8\x0f>\x0b\xb7\xb2u\xeaq\x22\xf8\x17\xe6\xe6\ +\xf9\xf7\x8d\x7f \x16\x93\xb2'\x1a\xc8\xe4\xd1\xb9\x02r\ +\xb7f\xb3a#k\xdc\xc7\x89\xb5\x1d\xca\xda\x9dy\xfc\ +\xfd;6\xb1}\xf7;\x89\x96t \xb9t\x94Z\x07\ +\xd5sl,\xa0Y|fuF\xaa\xbdc\xb2\x0dK\ +\xb6\x8dV\x03\x14\xa7\xdc\x1c]\xc3\x87`\x0c\x86E\xda\ +\x80&\x9aP\x988\xb7[c\xdegA\xc9]\xa2\x9e\ +\xfc\x17\xaa\x99\xb1\x8a\x12T)B\xc4\xbf\xff\x81\xdd|\ +h\xdf>,\x04\xa2\x82d\x81\x14@G\x1b\xa5\x98W\ +\x8a\x11Z\xa4UB\xc8\xb5^5\xa9X_c\xf2\x81\ +\x8b\x89\xf3\x22$\xc7UO\x88\xa2\xbc\xbey\x09\x7fk\ +i\x8d^\x7f\xc8j\x11\xe8d\xc7M\xe4\x9a#\xad2\ +\xe1r?\xd7F\xdd\x87\x86\x18c\xc3\xb6\x02\xa1\x19P\ +Sc\xa8r\xeb\xce*\x1e\x81\x85\xfe:N\x8c\xa1\xa5\ +\xb3\xbf\xcby,-\x95\xe3(\xa6:/\x1ay\x0d\xcd\ +\x8a\xc9\x1bQ\xd5\x8a}\xae\x7f2\xd0\xd0\xa0H\xa5j\ +\xc7\xe9\xe0\xea\x05]\x9a_\x5c\x99\x11\x97#\x91\xba\xb9\ +\x18\xd0\x01\x06\x0d\xe4\x15\x9f\xd5b\xce\xf2.\xd8\xd0E\ +%V\xdf\xc8\x9e\xe5\x9d\xb1\x02/\xd1\xd8:\xf3\x8e9\ +\x01\xda[\x1f<\xf4\x0d\x8c\xa9\xa4\xea\xa8\x06\x22\x22*\ +f\x1a,\x8a\x80X4A\xac\x12\xbf`\xa6`bf\ +\x22\xaa\x11\xb0\x181U\xa2\xa5\xfd<\x22b*\x12M\ +0A\xd2IM\xc4D$\x22\xb2\xbe\xf2\xea\x99\x1f\xff\ +\x17\x07v\xb3_\x89\xda\xcfC\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x05~\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\ +\x00\x00\x09pHYs\x00\x00\x0d\xd7\x00\x00\x0d\xd7\x01\ +B(\x9bx\x00\x00\x00\x07tIME\x07\xdb\x0c\x1e\ +\x16\x0e\x06\xaeP\xdc\x98\x00\x00\x04\xfeIDATX\ +\xc3\x9d\x97\xc1k\x14W\x1c\xc7?\xbf7o\xb2\x9b\xec\ +&n1E\x8a\x87\x12\xb4\x16\x92\x94TE\xdb\x88\x85\ +z\x11*\x0aF\xea\xc1\x92\xe0\xc9\x92\xfe\x0b\xdez\x10\ +\xa1\xd0C\xe9%x\xf2\x22\x88\xb7\xe2!\x81^\xdah\ +h\x14$i\xd2jm\xb4.ID\x93\x22I\x93\xc9\ +\xc6\xcd\xce\xcc\xebaw\xc6\xb7\xbb\xb3k\xf4\xc1\xf0f\ +\xde\xbcy\xbf\xef\xef\xfb\xfb\xfe~\xef\x0dl\xa3\x1d9\ +rD\x03\xdd@O\xd2\xd5\xdf\xdf\xafy\xcb&\xdb\x9c\ +\xf7\xa11\xe6/\x80\x9f\x7f\x1d\xc7\xd5\x9a \x08\xf8\xec\ +\xd3O\xd0Z\xe38\xceG\xc0\x1fo\x03`\xbb\xc87\ +&&&(\x14\x0a\xf4\xf7\xf7\x13\x86!\x8e\xe3p\xeb\ +\xd6-\xda\xda\xda\x006\xde\x96\x01\x0dp\xec\xd8\xb1\xcf\ +766n\xae\xae\xaef=\xcf\xc3\xf3<|\xdf\xa7\ +P(\xc4\x13\x1f=zd6\x0b\x05\xf9it\x0cG\ +)\x820\xa4\xf7\x83\xbd\xb4\xb6\xb5\x19\xe0\x1f\x00\x11\xa1\ +\xb5\xb5\x95T*E6\x9b\xa5\xbd\xbd\x9d\x5c.\xe7i\ +\xadO\x8d\x8f\x8f\xff\xd2\x10\x80\xef\xfb7O\x9c8\x91\ +9|\xf80ZkZZZ\x98\x9d\x9dexx\x98\ +\x97/_\xd2\xda\xda\xca\xd0\xd0\x90\xb4\xa4R\xec\xdd\xb3\ +\x07\x13\x86\x88RL\xde\xbd\xcbV\xb1(\xbe\xef\xb3\xb9\ +\xb9I*\x95\xe2\xc6\x8d\x1b\xec\xde\xbd\x9bb\xb1\x88\xef\ +\xfb\xdc\xbbw/3::z\x13ho\x06 \xbb\xb4\ +\xb4\xc4\xe5\xcb\x97)\x14\x0a\xa4\xd3i\x16\x17\x17\xd9\xb5\ +k\x17\x85B\x01\x11a``\x00c\x0c\xc6\x98\xf8\xe3\ +}\xfb\xf6!\x22\x5c\xbbv\x0dc\x0c\xe9t\x9aK\x97\ +.\xd1\xd1\xd1A\xb1X$\x93\xc9\xd0\xdb\xdb+A\x10\ +d\x9b\x8a\xf0\xe8\xd1\xa3Fk\xcd\xea\xea*SSS\ +\x00\x5c\xbdz\x95\xe3\xc7\x8f\xb3\xb9\xb9\xb9\xedx\xba\xae\ +\xcb\xd2\xd2\x12\x87\x0e\x1d\x02\xa0\xaf\xaf\x8f\x1d;vP\ +*\x95\x98\x9c\x9c\x94\x86\x0c\x84a\x88\x88\x10\x04\x01\x00\ ++++h\xady\xfc\xf81\xbe\xef\xd7}d\xb3P\ +\xe5ME\x03\x9e\xe7\x91\xcd\x96\x9dVJ\x11\x86as\ +\x11\x86a\x88R\x0a\x91W \xf7\xef\xdf\xcf\xc8\xc8\x08\ +\xae\xeb\xc6cJ\xa9\x18\xe0\xdc\xdc\x5c\xd5|\x11\xc1\x18\ +\xc3\xd9\xb3gy\xf0\xe0\x01\xb9\x5c\x8e|>\xcf\x8b\x17\ +/\x22\x16\xbf\x04\xdal?\x5c\xd7\x9d\x16\x80\x03\x07\x0e\ +\x98\x9d;w\xb2\xbc\xbc\xcc\xf4\xf44+++(\xa5\ +p]76b\xf7\xd7\xaf_\xe7\xfc\xf9\xf3o\x92m\ +&\xa9\xe6\x88\xc8`$BD\xa4\xca#c\x0c[[\ +[U\x86\xa3\xfbR\xa9\x04\xc0\xc2\xe2S\x94Hyu\ +%\x08\xe59JI\xd9\x9a\xc4\xbd\xa0T\x8c\xc0\x84!\ +\xefvvVeA\x95\x81F\xbd\x1d\x86\xe8;\xc7\xd1\ +(\x05\x18AT\xc5\xb8T_v\x98\x00Bk\x0d\x0d\ +\x10\x04A\xd5\xc4F \x92\xe68\x8e\xaa\x18U\xaf\xbc\ +\xb7\x8dWX\x88\x18\x01\x10K\xc4UY\x10y\xd7\x08\ +y\xed;\xc7Q\xb1aG\xa9r\x18$\x99\x01\x1b\x80\ +\xa9\x05\x10\x0d4\xa3>\xe9^)\x85\xd6e\x00\xa2\x14\ +\x0a\x90J6\xc5\x97\xfdm\xd47\x02`\x0b\xd0f#\ +\xc9\xfb\x08@\x94\xbeJ\x04\x22\x10\x91\xf1\x8a\xf0l\x00\ +\x92\xc4@T\x80js:\x09\x84\xddb\xe3\x15C\xaa\ +\x12\xf3\xe8\xb9\x16@\xd2:q\x16$\x090\x09\x84\xcd\ +\x82\xcd@t\xd5>'e\x97I\x12\xa1\xfd\xc26l\ +\x8c\xa93\x1e\x8b\xd0\xf2PE:P\xaa\xccB\x03\xe3\ +\x0dK\xb1\xddo7\x04\xd8\x0c(\x85c\xb1\xf0:Q\ +\xd7\x01\xa8\x15b\xd2d\xa91P\x15s[\x0f5\x8c\ +\xd9,\x96qK5\x80\xda\xd84\xd2@d\xc0\x9e\x17\ +\x83\xb0DY\xeb\xb1]=k\x1dS6\xe5\xb6\x06j\ +\xd1\xdb\x8b\xd6\x81\xaa\xa4`\xb3JZ;\xb6\xb6\xb6\xf6\ +\x8a\x81h\xcfn\xb4\xcf'eGT\xddl\x10I\xf5\ +_\xd5\x00\x8b\xda\xfa\xfa:\x80\xd1\xe5\x92\xea4=d\ +4\x1a\x93&J\x8f\x98\x1a\x1b\x1b\xe3\xf9\xf3g\xf8~\ +@\xa9T\xc2\xf7}\xb4\xd6&\x97{G\x80\xe5\x18@\ +\x18\x86MO.\x0d\xd0\x95\xab[\x93\xbd#\x9f\x7f\xc2\ +\xf0\xf07U\x85vaa^.|}\xe1\x0a\xf0\xbb\ +\x06\xd0Z\xd7i \x8a}\x920\xed\x03\xa5\xbc\x86\xb1\ + \x08\xed5\xcd\xdc\xdc\xdfr\xee\xdc\xb9\xef\xa7\xa6\xa6\ +\xbf\x03\xfe\x8d\x01\xd8\xe5\xb8\xa3\xa3\xa3\xa9\xe3\x99L\xe6\ +\xb5\xa1\x8a\x9e\xb7\xb6\xb6\xa2\x1acffg\xe4\xf4\xc0\ +\xe9o\xe7\xf3\xf3?\x02/b\x11F!\xe8\xec\xec\xa4\ +\xa7\xa7'>\x8e\xb9\xae[Wn\x1d\xc7\xe1\xe1\xc3\x87\ +\x9c9s\xe6M\xf4b\xee\xdc\x99\x0cN\x9e\xdf0\x14\xf1\xcf\xe4\xc6\x06kkk\ +x\x9e\xc7\xe0\xe0\xd0o\x0b\xf9\xfc\x0f\xc0V\x1dC\xdd\ +\xdd\xdd\xdc\xbf\x7f\xffM\xff)\xfb\x80\xf7\xb6\xf1w]\ +\x00\x16\x81'@b\x8a\xfd\x0fg\xea\x16`\x16W\xbd\ +\x9b\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x04\xef\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\ +\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\ +\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\ +tware\x00www.inksca\ +pe.org\x9b\xee<\x1a\x00\x00\x04lID\ +ATx\xda\xc5V\xdfo\x1bE\x10\x9e\xfbm\xc7v\ +b\xb7v\x13\x0a\x11\x22\x05\xa9\xa2\xef\xbc \xf1\x14\xde\ +\xf87\x80g\xd4\x97\x0a\x89\xf2\x04\xadT\xf8\x7f\x90\x8a\ +\x04\xaa*\xb5oE4\x84\x22\x88\xd4\x22L\x8c\x93\xda\ +q\xb1\xef\xec\xbb\xdb[fF\x1e\xfbzw\xe6B\xa4\ +\xd2M\xd63\xbbw7\xf3\xed7\xdf\xee\x9d\xad\xb5\x86\ +\x97\xd9\xec\xb95\xb0\xeb\x97\x09@_\xfd\xe4\xea\xedD\ +\xab]\xb9\xf0\xce\xbb\xef\xc1\xce\x9b\x97\xc1\xb1,\x80\x0c\ +K\x86\x81x\xb1\x9b\xa6\x01\xfcgH\xe7\x8bl\xf1\x12\ +\xcf\xc5Q\x0c{?\xfd\x08\xf7\xef\xde\x01i\xad\xd6\xb9\ +o\xaf\x7f~\xfd\xfd4\x00\xf0\xfd\xc9\xee\xc3\xbd\x87\xb0\ +\xb9\xb9\x05\x17:\x1d8\xdf\xb9\x00\xdb\xdb\xaf\x83\xe7:\ +\x85\xc8\x8d\xf9\x0fYN\x9e\xf5\x19\x84\x01\xfe,\x84~\ +\xff/\x98\x06S\x906H\x9e\xeef\x19\x80$I\xa0\ +V\xab\x81c\xdb\xec\x9b\xa0y\x85\xbf\xf7O\x8a\x92g\ +\x18y~`\xa4\x00\xbe\xd6n\xa0\xd1\x1c3\x9d\xab\x10\ +@s\xa3\x09\x9e\xe7-n\xb0,\x03\xdez\xb5\xc5\xbe\ +\xce&\xe7D\xe2\x8a\xe59a\x80\xc7\xc14\xa4K\xe5\ +\x00h74\x1a\x0d\xf1\xf9a\x0b\x19pm\x0bV\xd5\ +`5\x00\x89\x89\xc9\xa8C\xc21S\xb9\x0a\x19\xc8\xc4\ +\xd7\x08\x00o\xb0%\x9a\x5c(.\x83\x9e\x07\xa6\x84\x1a\ +\x7f\xd0\xe5q\xac\x14\x8f\xd3\xf1\x95R\xe5\x00\x12\x8d\xb8\ +\xf1\xc1\x9f\x1f\x1fCQ\xd3\x99\x91\xd6\xe21\x12\xb2<\ +\xd8:W\x03\x95\xe4JP\x0e\x80\x86\x11>\xb9\xbd\xb9\ +\xfe\xbc\xc84\xff\xe7\xe8$\xa3\x19\xc8\x82\x0d\xee\xb3P\ +\xd1\x8aO\xa7\x81tSq\x021u+\x0d\xac\x00\x88\ +\xac^\x92\xb2]\xfa1.Bi\xfd\xdf5\x10'\x0a\ +\x22\xda\x8e\x91Z]\x02Y\xbd\xb89\x06\x805\xa0\x12\ +}\x86\x12\xe0\xd3Q\xac\xa0w<\x96\xa92-H\xed\ +\xc5\xe5\xd6Z\xf7\x90MU.B\x9d\x01\xa0pL\xf4\ +5\x1b\x15\xd9W\xf9D\x99d<\xbb`\x80\x1d\x8a\xc1\ +\xc9\xd3\xf1u!\x039\x0dh\x16\xa1Q\xb2t\x9d\x06\ +%\xe3\xa5\x06\xc8a\x0d\xa4\xe3'\xa7\xd1\xc0\xbd\xbb\xdf\ +C\xaf\xd7\x83\xc0\xf7K\xe8/\x16\xa60S][\x83\ +'\xbf=\xcaj\xa0\xbc\x04O~\xfd\x85z\xfa\x0d\x98\ +\xb5\xc5\xa0V+\xbe\xa4\x048\xf9\x82[\xc9I\xf8\xff\ +}\x19\x15k@\xbfX\x06\xce^\x82\x0f?\xfe\x08\xde\ +\xd8\xd9)\xaa\xe5\xca:\x0f\x06\x03\xf8\xf4\xda50M\ +\x13\xe3j\xde\xd2\x9b\x9d\xce\xbf\x8b0\x8e\xe3\x5c`\xaf\ +\xe2\xb1\x8a\xc7\xe3\xb1\x08/+0\x9a\x93.s\xfca\ +S\xa9T\xc0\xb2,\xde\x09\xb3\xd9,\x1f_\x00\x14\x09\ +CZ\xfa\x04\xbb\xf1\xc5\x97t\xac\xca\xe7\x1b\xdc\xb8y\ +\x93V(\xa0\xe8>\xb2\xcb\x17\x0f\x832\x89o\xfa\xb2\ +\xa2\xf93\x00\x98\x07\xa5\xf6\xf4\xf8\x18\xa4\x0dON(\ +\x09\x01\xa0\x95\x91\xcf\xcfK\xd7\xcb\x17\x82\x98S\x00(\ +\xa0(\x9cN)x\x16 QK\xf3D-\xdb(\x8a\ +d\xe5\xc2\x02g\x150\x9a\x80!#\xd5j\x15j\xf5\ +:\x0c\x07\x83\x02\x0d\x14 \xb4l\xbb\x10@\x14G\x10\ +\x04\x81$ +L\x90\xe5\x1e\x22({\x0e4\xa6\x18\ +t\xaf\x01\x10\xe0\xa2\xa28>\x1d\x03\xe0\xba\x1c|\x8a\ +\x0f\xc5q$\xe5 +\xc2\xa2\xd5K\x09$9_\x8f\ +Bd\x87E\xa8\xf9\x85d\xa0\xb5M\x0b\x9e\x8dF\xd0\ +X_/\x02\xa0z`\xc0V\xf6\xc4\x0a\xfc\x00\x1c\xc7\ +\x811Z\x7f2a]4\x9bM\x14\xa2/I\x85\xfe\ +\x85\x0d\xc3\x10\x81\xd1|\xb2\x10\xa89\xdf\x9e\xedv\x1b\ +\xfa\xfd>\x18\xd846\x02\xc0\x83\xcb\x97.}P\xa9\ +\xd6\xbe\xc1R\xb5\xd3\xfbU\xe8\xbe\xf5\xd5-\x01E\x9d\ +\xb6&\x05\xcf\x89P\xfc(\x0cY+\x02\xc02M\x9e\ +\xefv\xbb,^lUL\x1b\x08\x03\xc6\xa3\x83\x83?\ +766\xbe\xde~\xe5\xe2gx\xc3\x1a\x8b0\x0a9\ +\x91=\xd7Bj\xc5D\xbd\xd0\x9f>\x8c(\xb8h*\ +]2\xd1\x90\x8cC4\xe7\xb1w\x05\x00\xdd\xf9\xf7h\ +4\xfa\x0e\x95\xdf\xf2<\xef\x22\x11\xf0\xf6\x95+\xd6\x0f\ +\x0f\x1e\xec\xb8\xae\xeb\xb9\x8e\xe3\xa2(]\x04\xe3a\x12\ +\x87\xca'\x87\x80\xe6/8\x15\x22\x980\xc26\x1c\x0e\ +}\xdb\xb1\x0f\x0e\x0f\x0fgx\xd5X\xe6`\xba\x15:\ +\xf7\xd0\x9ba\xe7\x12p\x0c\x8c\xe5\xa3\xbb\x17\xccf\x8f\ +\xb1;4\xfdG\xb7\x9bT\xaa\xd5\x10O\xb5D\xd0#\ +K\x80@\x89\x15\x8d\xc04\xd5\xb5^\xaf\x1bx\xfa\x19\ +\x84\xe7\x04\xcf\x88\xfd\xfd\xfd\xe4\xe8\xe8\xc8\x9dL&\x14\ +\xc7\xcc\x7f5p\xf2g\x94\xf7\x1f\xdf\x9a\xd2\x93\xfbC\ +\xb7\xa7\x00\x00\x00\x00IEND\xaeB`\x82\ +" + +qt_resource_name = b"\ +\x00\x05\ +\x00o\xa6S\ +\x00i\ +\x00c\x00o\x00n\x00s\ +\x00\x10\ +\x0c\xbc.g\ +\x00d\ +\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00n\x00e\x00w\x00.\x00p\x00n\x00g\ +\x00\x14\ +\x0b\xa9\xab'\ +\x00d\ +\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\ +\x00p\x00n\x00g\ +\x00\x14\ +\x0c\xbb2g\ +\x00t\ +\x00r\x00a\x00n\x00s\x00f\x00o\x00r\x00m\x00-\x00r\x00o\x00t\x00a\x00t\x00e\x00.\ +\x00p\x00n\x00g\ +\x00\x0d\ +\x03\xd2\xbeg\ +\x00e\ +\x00d\x00i\x00t\x00-\x00u\x00n\x00d\x00o\x00.\x00p\x00n\x00g\ +\x00\x0c\ +\x07\xb1Y'\ +\x00e\ +\x00d\x00i\x00t\x00-\x00c\x00u\x00t\x00.\x00p\x00n\x00g\ +\x00\x0d\ +\x01\x1c\xb1\xa7\ +\x00e\ +\x00d\x00i\x00t\x00-\x00c\x00o\x00p\x00y\x00.\x00p\x00n\x00g\ +\x00\x0d\ +\x0c\xd2\xbf\xe7\ +\x00e\ +\x00d\x00i\x00t\x00-\x00r\x00e\x00d\x00o\x00.\x00p\x00n\x00g\ +\x00\x11\ +\x01\xa6\xc4\x87\ +\x00d\ +\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00o\x00p\x00e\x00n\x00.\x00p\x00n\x00g\ +\ +\x00\x0e\ +\x0c\xaa\xc0\xa7\ +\x00e\ +\x00d\x00i\x00t\x00-\x00p\x00a\x00s\x00t\x00e\x00.\x00p\x00n\x00g\ +\x00\x11\ +\x0f\xe3\xd5g\ +\x00d\ +\x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00.\x00p\x00n\x00g\ +\ +" + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0a\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xfa\ +\x00\x00\x01\x9f{C\xf1'\ +\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00+,\ +\x00\x00\x01\x9f{0\xc99\ +\x00\x00\x00\x92\x00\x00\x00\x00\x00\x01\x00\x00\x14\x92\ +\x00\x00\x01\x9f{C\xf1\x18\ +\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x1cz\ +\x00\x00\x01\x9f{C\xf1.\ +\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\ +\x00\x00\x01\x9f{0\xc9B\ +\x00\x00\x018\x00\x00\x00\x00\x00\x01\x00\x0026\ +\x00\x00\x01\x9f{C\xf1N\ +\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\ +\x00\x00\x01\x9f{\x8d\xf34\ +\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x9f{0\xc9+\ +\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00#Z\ +\x00\x00\x01\x9f{C\xf1=\ +\x00\x00\x01Z\x00\x00\x00\x00\x00\x01\x00\x007\xb8\ +\x00\x00\x01\x9f{0\xc9R\ +" + +def qInitResources(): + QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + +qInitResources() diff --git a/BEdit/src/bedit/app.py b/BEdit/src/bedit/app.py index 6024441..68d7ccb 100644 --- a/BEdit/src/bedit/app.py +++ b/BEdit/src/bedit/app.py @@ -1,7 +1,7 @@ import sys from PySide6.QtCore import QCoreApplication, Qt -from PySide6.QtGui import QColor, QPalette, QIcon +from PySide6.QtGui import QColor, QPalette from PySide6.QtWidgets import QApplication, QStyleFactory from bedit.main_window import MainWindow diff --git a/BEdit/src/bedit/component_options_dialog.py b/BEdit/src/bedit/component_options_dialog.py new file mode 100644 index 0000000..85bc9af --- /dev/null +++ b/BEdit/src/bedit/component_options_dialog.py @@ -0,0 +1,31 @@ +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QDialog, QMessageBox + +from bedit.document.model import Component +from bedit.ui_component_options_dialog import Ui_ComponentOptionsDialog + + +class ComponentOptionsDialog(QDialog): + def __init__(self, component: Component, parent=None) -> None: + super().__init__(parent) + self.ui = Ui_ComponentOptionsDialog() + self.ui.setupUi(self) + self.ui.nameEdit.setText(component.name) + self.ui.shapeCombo.setCurrentText(component.icon.shape) + self.ui.iconTextEdit.setText(component.icon.text) + self.ui.fillEdit.setText(component.icon.fill) + self.ui.borderEdit.setText(component.icon.border) + self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library) + + def accept(self) -> None: + if not self.ui.nameEdit.text().strip(): + QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.") + return + for label, value in ( + ("fill", self.ui.fillEdit.text()), + ("border", self.ui.borderEdit.text()), + ): + if not QColor(value).isValid(): + QMessageBox.warning(self, "Invalid color", f"The {label} color is not valid.") + return + super().accept() diff --git a/BEdit/src/bedit/data/libraries/example.json b/BEdit/src/bedit/data/libraries/example.json new file mode 100644 index 0000000..39da028 --- /dev/null +++ b/BEdit/src/bedit/data/libraries/example.json @@ -0,0 +1,52 @@ +{ + "format": "bedit-document", + "version": 1, + "metadata": {"name": "Example Library"}, + "roots": [ + { + "id": "example-a", + "name": "Block A", + "position": {"x": 0, "y": 0}, + "interface": { + "inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}], + "outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}] + }, + "icon": {"shape": "rectangle", "fill": "#dbeafe", "border": "#245c9c", "text": "A"}, + "properties": {}, + "library": {"showSubtree": true}, + "implementation": { + "kind": "text", + "source": {"equations": ["out = gain * in"], "parameters": {"gain": 1.0}} + } + }, + { + "id": "example-b", + "name": "Block B", + "position": {"x": 0, "y": 0}, + "interface": { + "inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}], + "outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}] + }, + "icon": {"shape": "ellipse", "fill": "#dcfce7", "border": "#277342", "text": "B"}, + "properties": {}, + "library": {"showSubtree": true}, + "implementation": { + "kind": "text", + "source": {"equations": ["out = in + offset"], "parameters": {"offset": 0.0}} + } + }, + { + "id": "example-c", + "name": "Block C", + "position": {"x": 0, "y": 0}, + "interface": { + "inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}], + "outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}] + }, + "icon": {"shape": "rectangle", "fill": "#fef3c7", "border": "#8a641c", "text": "C"}, + "properties": {}, + "library": {"showSubtree": true}, + "implementation": {"kind": "graph", "graph": {"blocks": [], "connections": []}} + } + ] +} diff --git a/BEdit/src/bedit/data/libraries/test.bedit.json b/BEdit/src/bedit/data/libraries/test.bedit.json new file mode 100644 index 0000000..88faea7 --- /dev/null +++ b/BEdit/src/bedit/data/libraries/test.bedit.json @@ -0,0 +1,351 @@ +{ + "format": "bedit-document", + "version": 1, + "metadata": { + "name": "Untitled" + }, + "roots": [ + { + "id": "97cd3d0b-c36a-467e-b1f5-4c794979bd99", + "name": "something", + "position": { + "x": 0.0, + "y": 0.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "in", + "name": "Input", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": {} + } + ], + "outputs": [] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Something" + }, + "properties": {}, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": [], + "parameters": {} + } + } + }, + { + "id": "614b4018-c00e-40ce-b59c-5d5795eab7d0", + "name": "Test", + "position": { + "x": 0.0, + "y": 0.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [], + "outputs": [] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Test" + }, + "properties": {}, + "library": { + "showSubtree": false + }, + "implementation": { + "kind": "graph", + "graph": { + "blocks": [ + { + "id": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce", + "name": "sin", + "position": { + "x": -28.0, + "y": -179.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "input-0bcb214f", + "name": "a", + "position": { + "x": -223.0, + "y": -161.0 + }, + "properties": {} + } + ], + "outputs": [ + { + "id": "output-a55cd289", + "name": "b", + "position": { + "x": 133.0, + "y": -152.0 + }, + "properties": {} + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "sin()" + }, + "properties": {}, + "library": { + "showSubtree": false + }, + "implementation": { + "kind": "graph", + "graph": { + "blocks": [], + "connections": [ + { + "id": "cef30af9-fdc5-4a28-a7d6-4ab3c83e65d7", + "source": { + "interface": "input-0bcb214f" + }, + "target": { + "interface": "output-a55cd289" + }, + "name": "", + "properties": {} + } + ] + } + } + }, + { + "id": "88f40d88-f62b-432f-a6a0-95513b48409e", + "name": "constant", + "position": { + "x": -260.0, + "y": -178.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [], + "outputs": [ + { + "id": "output-1e15ff3f", + "name": "Output 1", + "position": { + "x": 71.0, + "y": -134.0 + }, + "properties": {} + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "C" + }, + "properties": {}, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "graph", + "graph": { + "blocks": [], + "connections": [] + } + } + }, + { + "id": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5", + "name": "New Text Block 1", + "position": { + "x": 225.0, + "y": -167.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "in", + "name": "Input", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": {} + } + ], + "outputs": [] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text" + }, + "properties": {}, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": [], + "parameters": {} + } + } + } + ], + "connections": [ + { + "id": "b3837790-26ba-46a6-91f9-3b6808c7ad1a", + "source": { + "block": "88f40d88-f62b-432f-a6a0-95513b48409e", + "port": "output-1e15ff3f" + }, + "target": { + "block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce", + "port": "input-0bcb214f" + }, + "name": "", + "properties": {} + }, + { + "id": "50b2c7aa-d824-407c-ae0f-6b3bb4b1b79e", + "source": { + "block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce", + "port": "output-a55cd289" + }, + "target": { + "block": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5", + "port": "in" + }, + "name": "", + "properties": {} + } + ] + } + } + }, + { + "id": "465796f5-9075-45f6-81b9-7fa17476392c", + "name": "sin", + "position": { + "x": 0.0, + "y": 0.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "input-0bcb214f", + "name": "Input 1", + "position": { + "x": -223.0, + "y": -161.0 + }, + "properties": {} + } + ], + "outputs": [ + { + "id": "output-a55cd289", + "name": "Output 1", + "position": { + "x": 133.0, + "y": -152.0 + }, + "properties": {} + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "sin()" + }, + "properties": {}, + "library": { + "showSubtree": false + }, + "implementation": { + "kind": "graph", + "graph": { + "blocks": [], + "connections": [ + { + "id": "029ccfaa-7150-4a53-beec-286ff8e2a628", + "source": { + "interface": "input-0bcb214f" + }, + "target": { + "interface": "output-a55cd289" + }, + "name": "", + "properties": {} + } + ] + } + } + }, + { + "id": "bb0adff3-baf2-469b-9a80-f05910d0f259", + "name": "constant", + "position": { + "x": 0.0, + "y": 0.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [], + "outputs": [ + { + "id": "output-1e15ff3f", + "name": "Output 1", + "position": { + "x": 71.0, + "y": -134.0 + }, + "properties": {} + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "C" + }, + "properties": {}, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "graph", + "graph": { + "blocks": [], + "connections": [] + } + } + } + ] +} diff --git a/BEdit/src/bedit/document/__init__.py b/BEdit/src/bedit/document/__init__.py new file mode 100644 index 0000000..33fe6a5 --- /dev/null +++ b/BEdit/src/bedit/document/__init__.py @@ -0,0 +1,13 @@ +from bedit.document.controller import DocumentController +from bedit.document.model import Component, Connection, Endpoint, Graph, GraphDocument, Icon, Port + +__all__ = [ + "Component", + "Connection", + "DocumentController", + "Endpoint", + "Graph", + "GraphDocument", + "Icon", + "Port", +] diff --git a/BEdit/src/bedit/document/commands.py b/BEdit/src/bedit/document/commands.py new file mode 100644 index 0000000..b3e2122 --- /dev/null +++ b/BEdit/src/bedit/document/commands.py @@ -0,0 +1,262 @@ +from PySide6.QtCore import QPointF +from PySide6.QtGui import QUndoCommand + +from bedit.document.model import Component, Connection, Port + + +class AddComponentCommand(QUndoCommand): + def __init__(self, controller, owner_id: str | None, component: Component) -> None: + super().__init__(f"Add {component.name}") + self.controller = controller + self.owner_id = owner_id + self.component = component + + def redo(self) -> None: + self.controller._insert_component(self.owner_id, self.component) + + def undo(self) -> None: + self.controller._remove_component(self.owner_id, self.component.id) + + +class MoveComponentCommand(QUndoCommand): + def __init__( + self, + controller, + owner_id: str, + component_id: str, + old: QPointF, + new: QPointF, + ) -> None: + super().__init__("Move component") + self.controller = controller + self.owner_id = owner_id + self.component_id = component_id + self.old = old + self.new = new + + def redo(self) -> None: + self.controller._move_component(self.owner_id, self.component_id, self.new) + + def undo(self) -> None: + self.controller._move_component(self.owner_id, self.component_id, self.old) + + +class RotateComponentsCommand(QUndoCommand): + def __init__( + self, + controller, + owner_id: str, + rotations: dict[str, tuple[float, float]], + ) -> None: + super().__init__("Rotate components") + self.controller = controller + self.owner_id = owner_id + self.rotations = rotations + + def redo(self) -> None: + for component_id, (_old, new) in self.rotations.items(): + self.controller._rotate_component(self.owner_id, component_id, new) + + def undo(self) -> None: + for component_id, (old, _new) in self.rotations.items(): + self.controller._rotate_component(self.owner_id, component_id, old) + + +class AddConnectionCommand(QUndoCommand): + def __init__(self, controller, owner_id: str, connection: Connection) -> None: + super().__init__("Connect components") + self.controller = controller + self.owner_id = owner_id + self.connection = connection + + def redo(self) -> None: + self.controller._insert_connection(self.owner_id, self.connection) + + def undo(self) -> None: + self.controller._remove_connection(self.owner_id, self.connection.id) + + +class ReplaceComponentCommand(QUndoCommand): + def __init__(self, controller, old: Component, new: Component) -> None: + super().__init__("Apply JSON changes") + self.controller = controller + self.old = old + self.new = new + + def redo(self) -> None: + self.controller._replace_component(self.old.id, self.new) + + def undo(self) -> None: + self.controller._replace_component(self.new.id, self.old) + + +class AddInterfacePortCommand(QUndoCommand): + def __init__(self, controller, owner_id: str, direction: str, port: Port) -> None: + super().__init__(f"Add {direction}") + self.controller = controller + self.owner_id = owner_id + self.direction = direction + self.port = port + + def redo(self) -> None: + self.controller._insert_interface_port(self.owner_id, self.direction, self.port) + + def undo(self) -> None: + self.controller._remove_interface_port(self.owner_id, self.direction, self.port.id) + + +class ReplaceSourceCommand(QUndoCommand): + def __init__(self, controller, component_id: str, old: dict, new: dict) -> None: + super().__init__("Apply source JSON") + self.controller = controller + self.component_id = component_id + self.old = old + self.new = new + + def redo(self) -> None: + self.controller._replace_source(self.component_id, self.new) + + def undo(self) -> None: + self.controller._replace_source(self.component_id, self.old) + + +class MoveInterfacePortCommand(QUndoCommand): + def __init__(self, controller, owner_id: str, port_id: str, old: QPointF, new: QPointF) -> None: + super().__init__("Move interface terminal") + self.controller = controller + self.owner_id = owner_id + self.port_id = port_id + self.old = old + self.new = new + + def redo(self) -> None: + self.controller._move_interface_port(self.owner_id, self.port_id, self.new) + + def undo(self) -> None: + self.controller._move_interface_port(self.owner_id, self.port_id, self.old) + + +class EditComponentAppearanceCommand(QUndoCommand): + def __init__(self, controller, component_id: str, old: dict, new: dict) -> None: + super().__init__("Edit component appearance") + self.controller = controller + self.component_id = component_id + self.old = old + self.new = new + + def redo(self) -> None: + self.controller._set_component_appearance(self.component_id, self.new) + + def undo(self) -> None: + self.controller._set_component_appearance(self.component_id, self.old) + + +class RenameInterfacePortCommand(QUndoCommand): + def __init__(self, controller, owner_id: str, port_id: str, old: str, new: str) -> None: + super().__init__("Rename interface port") + self.controller, self.owner_id, self.port_id = controller, owner_id, port_id + self.old, self.new = old, new + + def redo(self) -> None: + self.controller._rename_interface_port(self.owner_id, self.port_id, self.new) + + def undo(self) -> None: + self.controller._rename_interface_port(self.owner_id, self.port_id, self.old) + + +class RenameConnectionCommand(QUndoCommand): + def __init__(self, controller, owner_id: str, connection_id: str, old: str, new: str) -> None: + super().__init__("Rename connection") + self.controller, self.owner_id, self.connection_id = controller, owner_id, connection_id + self.old, self.new = old, new + + def redo(self) -> None: + self.controller._rename_connection(self.owner_id, self.connection_id, self.new) + + def undo(self) -> None: + self.controller._rename_connection(self.owner_id, self.connection_id, self.old) + + +class DeleteSelectionCommand(QUndoCommand): + def __init__( + self, + controller, + owner_id: str | None, + blocks: dict[str, Component], + connections: dict[str, Connection], + inputs: list[Port], + outputs: list[Port], + ) -> None: + super().__init__("Delete selection") + self.controller = controller + self.owner_id = owner_id + self.blocks = blocks + self.connections = connections + self.inputs = inputs + self.outputs = outputs + + def redo(self) -> None: + self.controller._delete_items( + self.owner_id, + set(self.blocks), + set(self.connections), + {port.id for port in self.inputs}, + {port.id for port in self.outputs}, + ) + + def undo(self) -> None: + self.controller._restore_items( + self.owner_id, + self.blocks, + self.connections, + self.inputs, + self.outputs, + ) + + +class PasteSelectionCommand(QUndoCommand): + def __init__( + self, + controller, + owner_id: str, + blocks: dict[str, Component], + connections: dict[str, Connection], + ) -> None: + super().__init__("Paste selection") + self.controller = controller + self.owner_id = owner_id + self.blocks = blocks + self.connections = connections + + def redo(self) -> None: + self.controller._restore_items( + self.owner_id, + self.blocks, + self.connections, + [], + [], + ) + + def undo(self) -> None: + self.controller._delete_items( + self.owner_id, + set(self.blocks), + set(self.connections), + set(), + set(), + ) + + +class EditTextDefinitionCommand(QUndoCommand): + def __init__(self, controller, component_id: str, old: dict, new: dict) -> None: + super().__init__("Edit text component") + self.controller = controller + self.component_id = component_id + self.old = old + self.new = new + + def redo(self) -> None: + self.controller._set_text_definition(self.component_id, self.new) + + def undo(self) -> None: + self.controller._set_text_definition(self.component_id, self.old) diff --git a/BEdit/src/bedit/document/controller.py b/BEdit/src/bedit/document/controller.py new file mode 100644 index 0000000..01818c0 --- /dev/null +++ b/BEdit/src/bedit/document/controller.py @@ -0,0 +1,673 @@ +from copy import deepcopy +from pathlib import Path +from uuid import uuid4 + +from PySide6.QtCore import QObject, QPointF, Signal +from PySide6.QtGui import QUndoStack + +from bedit.document.commands import ( + AddComponentCommand, + AddConnectionCommand, + AddInterfacePortCommand, + DeleteSelectionCommand, + EditTextDefinitionCommand, + EditComponentAppearanceCommand, + MoveComponentCommand, + MoveInterfacePortCommand, + PasteSelectionCommand, + RenameConnectionCommand, + RenameInterfacePortCommand, + ReplaceSourceCommand, + RotateComponentsCommand, +) +from bedit.document.model import ( + Component, + Connection, + Endpoint, + GraphDocument, + Icon, + Port, + clone_component, +) +from bedit.document.serializer import JsonDocumentSerializer + + +class DocumentController(QObject): + documentReset = Signal() + documentOpenedChanged = Signal(bool) + activeGraphChanged = Signal() + componentAdded = Signal(str) + componentRemoved = Signal(str) + componentMoved = Signal(str, QPointF) + componentRotated = Signal(str, float) + connectionAdded = Signal(str) + connectionRemoved = Signal(str) + interfaceChanged = Signal() + filePathChanged = Signal(object) + modifiedChanged = Signal(bool) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.document: GraphDocument | None = None + self.active_component_id: str | None = None + self.file_path: Path | None = None + self.undo_stack = QUndoStack(self) + self.undo_stack.cleanChanged.connect(self._clean_changed) + + def _clean_changed(self, clean: bool) -> None: + self.modifiedChanged.emit(not clean) + + @property + def active_component(self) -> Component | None: + if self.document is None or self.active_component_id is None: + return None + return self.document.find_component(self.active_component_id) + + @property + def active_graph(self): + component = self.active_component + if component is None or component.implementation_kind != "graph": + raise ValueError("There is no active graph") + return component.graph + + def new_document(self) -> None: + self.document = GraphDocument.empty() + self.active_component_id = None + self.file_path = None + self.undo_stack.clear() + self.documentOpenedChanged.emit(True) + self.documentReset.emit() + self.activeGraphChanged.emit() + self.filePathChanged.emit(None) + + def close_document(self) -> None: + self.document = None + self.active_component_id = None + self.file_path = None + self.undo_stack.clear() + self.documentOpenedChanged.emit(False) + self.documentReset.emit() + self.activeGraphChanged.emit() + self.filePathChanged.emit(None) + + def load(self, path: Path) -> None: + self.document = JsonDocumentSerializer.load(path) + self.active_component_id = next(iter(self.document.roots), None) + self.file_path = path + self.undo_stack.clear() + self.undo_stack.setClean() + self.documentOpenedChanged.emit(True) + self.documentReset.emit() + self.activeGraphChanged.emit() + self.filePathChanged.emit(path) + + def save(self, path: Path | None = None) -> Path: + if self.document is None: + raise ValueError("There is no open document") + target = path or self.file_path + if target is None: + raise ValueError("No file path has been selected") + JsonDocumentSerializer.save(self.document, target) + self.file_path = target + self.undo_stack.setClean() + self.filePathChanged.emit(target) + return target + + def activate_component(self, component_id: str) -> None: + if self.document is None or self.document.find_component(component_id) is None: + return + self.active_component_id = component_id + self.activeGraphChanged.emit() + + def navigate_up(self) -> None: + if self.document is None or self.active_component_id is None: + return + parent = self.document.find_parent(self.active_component_id) + if parent is not None: + self.activate_component(parent.id) + + def breadcrumb(self) -> list[str]: + if self.document is None or self.active_component is None: + return [] + names = [self.active_component.name] + current = self.active_component + while True: + parent = self.document.find_parent(current.id) + if parent is None: + break + names.append(parent.name) + current = parent + return list(reversed(names)) + + def add_root(self, kind: str) -> str: + if self.document is None: + raise ValueError("Open or create a document first") + number = len(self.document.roots) + 1 + component = Component( + id=str(uuid4()), + name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}", + implementation_kind=kind, + icon=Icon(text="Graph" if kind == "graph" else "Text"), + source={"equations": [], "parameters": {}} if kind == "text" else {}, + ) + self.undo_stack.push(AddComponentCommand(self, None, component)) + self.activate_component(component.id) + return component.id + + def add_child(self, owner_id: str, kind: str) -> str: + if self.document is None: + raise ValueError("Open or create a document first") + owner = self.document.find_component(owner_id) + if owner is None or owner.implementation_kind != "graph": + raise ValueError("Children can only be added to graph components") + number = len(owner.graph.blocks) + 1 + component = Component( + id=str(uuid4()), + name=f"New {'Graph' if kind == 'graph' else 'Text'} Block {number}", + implementation_kind=kind, + icon=Icon(text="Graph" if kind == "graph" else "Text"), + source={"equations": [], "parameters": {}} if kind == "text" else {}, + ) + self.undo_stack.push(AddComponentCommand(self, owner_id, component)) + return component.id + + def delete_component(self, component_id: str) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + parent = self.document.find_parent(component_id) + owner_id = parent.id if parent else None + connections = {} + if parent is not None: + connections = { + connection.id: connection + for connection in parent.graph.connections.values() + if component_id in (connection.source.block, connection.target.block) + } + self.undo_stack.push( + DeleteSelectionCommand( + self, + owner_id, + {component_id: component}, + connections, + [], + [], + ) + ) + + def add_component_copy(self, source: Component, position: QPointF) -> str: + if self.active_component is None or self.active_component.implementation_kind != "graph": + raise ValueError("Open a graph component before placing components") + component = clone_component(source) + component.x, component.y = position.x(), position.y() + self.undo_stack.push(AddComponentCommand(self, self.active_component_id, component)) + return component.id + + def move_component(self, component_id: str, old: QPointF, new: QPointF) -> None: + if old != new and self.active_component_id is not None: + self.undo_stack.push( + MoveComponentCommand(self, self.active_component_id, component_id, old, new) + ) + + def rotate_components(self, component_ids: set[str]) -> None: + if self.active_component is None or self.active_component_id is None: + return + rotations = { + component_id: (component.rotation, (component.rotation + 90.0) % 360.0) + for component_id in component_ids + if (component := self.active_component.graph.blocks.get(component_id)) is not None + } + if rotations: + self.undo_stack.push( + RotateComponentsCommand(self, self.active_component_id, rotations) + ) + + def connect(self, source: Endpoint, target: Endpoint) -> str: + if self.active_component_id is None: + raise ValueError("There is no active graph") + connection = Connection(str(uuid4()), source, target) + self.undo_stack.push( + AddConnectionCommand(self, self.active_component_id, connection) + ) + return connection.id + + def add_interface_port(self, direction: str, position: QPointF) -> str: + component = self.active_component + if component is None or component.implementation_kind != "graph": + raise ValueError("Open a graph component before adding an interface") + ports = component.inputs if direction == "input" else component.outputs + port = Port( + id=f"{direction}-{uuid4().hex[:8]}", + name=f"{direction.title()} {len(ports) + 1}", + x=position.x(), + y=position.y(), + ) + self.undo_stack.push( + AddInterfacePortCommand(self, component.id, direction, port) + ) + return port.id + + def move_interface_port(self, port_id: str, old: QPointF, new: QPointF) -> None: + if old != new and self.active_component_id is not None: + self.undo_stack.push( + MoveInterfacePortCommand(self, self.active_component_id, port_id, old, new) + ) + + def rename_interface_port(self, port_id: str, name: str) -> None: + owner = self.active_component + if owner is None: + return + port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None) + if port is not None and port.name != name: + self.undo_stack.push( + RenameInterfacePortCommand(self, owner.id, port_id, port.name, name) + ) + + def rename_connection(self, connection_id: str, name: str) -> None: + owner = self.active_component + if owner is None or owner.implementation_kind != "graph": + return + connection = owner.graph.connections.get(connection_id) + if connection is not None and connection.name != name: + self.undo_stack.push( + RenameConnectionCommand(self, owner.id, connection_id, connection.name, name) + ) + + def replace_active_source(self, source: dict) -> None: + component = self.active_component + if component is None or component.implementation_kind != "text": + raise ValueError("Only text-defined components have source JSON") + self.undo_stack.push( + ReplaceSourceCommand( + self, + component.id, + deepcopy(component.source), + deepcopy(source), + ) + ) + + def replace_active_text_definition( + self, + inputs: list[Port], + outputs: list[Port], + source: dict, + ) -> None: + component = self.active_component + if component is None or component.implementation_kind != "text": + raise ValueError("Only text-defined components can be edited here") + input_ids = [port.id for port in inputs] + output_ids = [port.id for port in outputs] + if len(set(input_ids)) != len(input_ids) or len(set(output_ids)) != len(output_ids): + raise ValueError("Input and output IDs must be unique") + if self.document is not None: + parent = self.document.find_parent(component.id) + if parent is not None: + for connection in parent.graph.connections.values(): + if connection.target.block == component.id and connection.target.port not in input_ids: + raise ValueError( + f"Input {connection.target.port!r} is still connected in the containing graph" + ) + if connection.source.block == component.id and connection.source.port not in output_ids: + raise ValueError( + f"Output {connection.source.port!r} is still connected in the containing graph" + ) + old = { + "inputs": [port.to_dict() for port in component.inputs], + "outputs": [port.to_dict() for port in component.outputs], + "source": deepcopy(component.source), + } + new = { + "inputs": [port.to_dict() for port in inputs], + "outputs": [port.to_dict() for port in outputs], + "source": deepcopy(source), + } + if old != new: + self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new)) + + def edit_component_appearance( + self, + component_id: str, + name: str, + shape: str, + fill: str, + border: str, + text: str, + show_subtree: bool, + ) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + old = { + "name": component.name, + **component.icon.to_dict(), + "show_subtree": component.show_subtree_in_library, + } + new = { + "name": name, + "shape": shape, + "fill": fill, + "border": border, + "text": text, + "show_subtree": show_subtree, + } + if old != new: + self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) + + def delete_selection( + self, + block_ids: set[str], + connection_ids: set[str], + input_ids: set[str], + output_ids: set[str], + ) -> None: + component = self.active_component + if component is None or component.implementation_kind != "graph": + return + graph = component.graph + all_connection_ids = set(connection_ids) + for connection in graph.connections.values(): + if ( + connection.source.block in block_ids + or connection.target.block in block_ids + or connection.source.interface in input_ids + or connection.target.interface in output_ids + ): + all_connection_ids.add(connection.id) + blocks = {block_id: graph.blocks[block_id] for block_id in block_ids if block_id in graph.blocks} + connections = { + connection_id: graph.connections[connection_id] + for connection_id in all_connection_ids + if connection_id in graph.connections + } + inputs = [port for port in component.inputs if port.id in input_ids] + outputs = [port for port in component.outputs if port.id in output_ids] + if not (blocks or connections or inputs or outputs): + return + self.undo_stack.push( + DeleteSelectionCommand( + self, + component.id, + blocks, + connections, + inputs, + outputs, + ) + ) + + def paste_selection( + self, + source_components: list[Component], + source_connections: list[Connection], + offset: QPointF, + ) -> list[str]: + owner = self.active_component + if owner is None or owner.implementation_kind != "graph": + return [] + pairs = [(source, clone_component(source)) for source in source_components] + id_map = {source.id: clone.id for source, clone in pairs} + blocks = {} + for _source, clone in pairs: + clone.x += offset.x() + clone.y += offset.y() + blocks[clone.id] = clone + connections = {} + for source in source_connections: + if source.source.block not in id_map or source.target.block not in id_map: + continue + connection = Connection( + id=str(uuid4()), + source=Endpoint(block=id_map[source.source.block], port=source.source.port), + target=Endpoint(block=id_map[source.target.block], port=source.target.port), + ) + connections[connection.id] = connection + if blocks: + self.undo_stack.push( + PasteSelectionCommand(self, owner.id, blocks, connections) + ) + return list(blocks) + + def _graph_for(self, owner_id: str): + if self.document is None: + raise ValueError("There is no open document") + owner = self.document.find_component(owner_id) + if owner is None: + raise ValueError("The containing component is no longer in the document") + return owner.graph + + def _insert_component(self, owner_id: str | None, component: Component) -> None: + if self.document is None: + raise ValueError("There is no open document") + if owner_id is None: + self.document.roots[component.id] = component + else: + self._graph_for(owner_id).blocks[component.id] = component + if owner_id == self.active_component_id: + self.componentAdded.emit(component.id) + self.documentReset.emit() + + def _remove_component(self, owner_id: str | None, component_id: str) -> None: + if self.document is None: + return + if owner_id is None: + self.document.roots.pop(component_id, None) + if self.active_component_id == component_id: + self.active_component_id = next(iter(self.document.roots), None) + self.activeGraphChanged.emit() + else: + self._graph_for(owner_id).blocks.pop(component_id, None) + if owner_id == self.active_component_id: + self.componentRemoved.emit(component_id) + self.documentReset.emit() + + def _move_component(self, owner_id: str, component_id: str, position: QPointF) -> None: + component = self._graph_for(owner_id).blocks[component_id] + component.x, component.y = position.x(), position.y() + if owner_id == self.active_component_id: + self.componentMoved.emit(component_id, position) + self.documentReset.emit() + + def _rotate_component( + self, owner_id: str, component_id: str, rotation: float + ) -> None: + component = self._graph_for(owner_id).blocks.get(component_id) + if component is None: + return + component.rotation = rotation + if owner_id == self.active_component_id: + self.componentRotated.emit(component_id, rotation) + + def _insert_connection(self, owner_id: str, connection: Connection) -> None: + self._graph_for(owner_id).connections[connection.id] = connection + if owner_id == self.active_component_id: + self.connectionAdded.emit(connection.id) + self.documentReset.emit() + + def _remove_connection(self, owner_id: str, connection_id: str) -> None: + self._graph_for(owner_id).connections.pop(connection_id, None) + if owner_id == self.active_component_id: + self.connectionRemoved.emit(connection_id) + self.documentReset.emit() + + def _replace_component(self, old_id: str, replacement: Component) -> None: + if self.document is None: + return + was_active = self.active_component_id == old_id + if old_id in self.document.roots: + self.document.roots.pop(old_id) + self.document.roots[replacement.id] = replacement + else: + parent = self.document.find_parent(old_id) + if parent is None: + raise ValueError("The component is no longer in this document") + parent.graph.blocks.pop(old_id) + parent.graph.blocks[replacement.id] = replacement + if was_active: + self.active_component_id = replacement.id + self.document.validate() + self.documentReset.emit() + if was_active: + self.activeGraphChanged.emit() + + def _insert_interface_port(self, owner_id: str, direction: str, port: Port) -> None: + if self.document is None: + return + owner = self.document.find_component(owner_id) + if owner is None: + return + ports = owner.inputs if direction == "input" else owner.outputs + if all(existing.id != port.id for existing in ports): + ports.append(port) + self.interfaceChanged.emit() + self.documentReset.emit() + + def _remove_interface_port(self, owner_id: str, direction: str, port_id: str) -> None: + if self.document is None: + return + owner = self.document.find_component(owner_id) + if owner is None: + return + ports = owner.inputs if direction == "input" else owner.outputs + ports[:] = [port for port in ports if port.id != port_id] + self.interfaceChanged.emit() + self.documentReset.emit() + + def _move_interface_port(self, owner_id: str, port_id: str, position: QPointF) -> None: + if self.document is None: + return + owner = self.document.find_component(owner_id) + if owner is None: + return + port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None) + if port is not None: + port.x, port.y = position.x(), position.y() + self.interfaceChanged.emit() + self.documentReset.emit() + + def _rename_interface_port(self, owner_id: str, port_id: str, name: str) -> None: + owner = self.document.find_component(owner_id) if self.document else None + if owner is None: + return + port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None) + if port is not None: + port.name = name + self.interfaceChanged.emit() + self.documentReset.emit() + + def _rename_connection(self, owner_id: str, connection_id: str, name: str) -> None: + connection = self._graph_for(owner_id).connections.get(connection_id) + if connection is not None: + connection.name = name + self.documentReset.emit() + + def _replace_source(self, component_id: str, source: dict) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + component.source = deepcopy(source) + self.documentReset.emit() + if component_id == self.active_component_id: + self.activeGraphChanged.emit() + + def _set_component_appearance(self, component_id: str, values: dict) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + component.name = values["name"] + component.icon = Icon( + shape=values["shape"], + fill=values["fill"], + border=values["border"], + text=values["text"], + ) + component.show_subtree_in_library = values["show_subtree"] + self.documentReset.emit() + if component_id == self.active_component_id: + self.activeGraphChanged.emit() + + def _delete_items( + self, + owner_id: str | None, + block_ids: set[str], + connection_ids: set[str], + input_ids: set[str], + output_ids: set[str], + ) -> None: + if self.document is None: + return + deleted_component_ids: set[str] = set() + for block_id in block_ids: + component = self.document.find_component(block_id) + if component is not None: + deleted_component_ids.update( + child.id for child in self._component_subtree(component) + ) + active_was_deleted = self.active_component_id in deleted_component_ids + if owner_id is None: + for block_id in block_ids: + self.document.roots.pop(block_id, None) + else: + owner = self.document.find_component(owner_id) + if owner is None: + return + for block_id in block_ids: + owner.graph.blocks.pop(block_id, None) + for connection_id in connection_ids: + owner.graph.connections.pop(connection_id, None) + owner.inputs[:] = [port for port in owner.inputs if port.id not in input_ids] + owner.outputs[:] = [port for port in owner.outputs if port.id not in output_ids] + if active_was_deleted: + self.active_component_id = owner_id or next(iter(self.document.roots), None) + self.activeGraphChanged.emit() + self.documentReset.emit() + + def _restore_items( + self, + owner_id: str | None, + blocks: dict[str, Component], + connections: dict[str, Connection], + inputs: list[Port], + outputs: list[Port], + ) -> None: + if self.document is None: + return + if owner_id is None: + self.document.roots.update(blocks) + else: + owner = self.document.find_component(owner_id) + if owner is None: + return + owner.graph.blocks.update(blocks) + owner.graph.connections.update(connections) + existing_inputs = {port.id for port in owner.inputs} + existing_outputs = {port.id for port in owner.outputs} + owner.inputs.extend(port for port in inputs if port.id not in existing_inputs) + owner.outputs.extend(port for port in outputs if port.id not in existing_outputs) + self.documentReset.emit() + + @staticmethod + def _component_subtree(component: Component): + yield component + for child in component.graph.blocks.values(): + yield from DocumentController._component_subtree(child) + + def _set_text_definition(self, component_id: str, values: dict) -> None: + if self.document is None: + return + component = self.document.find_component(component_id) + if component is None: + return + component.inputs = [Port.from_dict(item) for item in values["inputs"]] + component.outputs = [Port.from_dict(item) for item in values["outputs"]] + component.source = deepcopy(values["source"]) + self.interfaceChanged.emit() + self.documentReset.emit() + if component_id == self.active_component_id: + self.activeGraphChanged.emit() diff --git a/BEdit/src/bedit/document/model.py b/BEdit/src/bedit/document/model.py new file mode 100644 index 0000000..aae44e6 --- /dev/null +++ b/BEdit/src/bedit/document/model.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any +from uuid import uuid4 + + +@dataclass +class Port: + id: str + name: str + x: float = 0.0 + y: float = 0.0 + properties: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "position": {"x": self.x, "y": self.y}, + "properties": self.properties, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Port": + position = data.get("position", {}) + return cls( + id=str(data["id"]), + name=str(data.get("name", data["id"])), + x=float(position.get("x", 0.0)), + y=float(position.get("y", 0.0)), + properties=dict(data.get("properties", {})), + ) + + +@dataclass +class Icon: + shape: str = "rectangle" + fill: str = "#f4f4f4" + border: str = "#303030" + text: str = "" + + def to_dict(self) -> dict[str, str]: + return { + "shape": self.shape, + "fill": self.fill, + "border": self.border, + "text": self.text, + } + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "Icon": + data = data or {} + return cls( + shape=str(data.get("shape", "rectangle")), + fill=str(data.get("fill", "#f4f4f4")), + border=str(data.get("border", "#303030")), + text=str(data.get("text", "")), + ) + + +@dataclass(frozen=True) +class Endpoint: + block: str | None = None + port: str | None = None + interface: str | None = None + + def to_dict(self) -> dict[str, str]: + if self.interface is not None: + return {"interface": self.interface} + if self.block is None or self.port is None: + raise ValueError("A block endpoint requires both block and port") + return {"block": self.block, "port": self.port} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Endpoint": + if "interface" in data: + return cls(interface=str(data["interface"])) + return cls(block=str(data["block"]), port=str(data["port"])) + + +@dataclass +class Connection: + id: str + source: Endpoint + target: Endpoint + name: str = "" + properties: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "source": self.source.to_dict(), + "target": self.target.to_dict(), + "name": self.name, + "properties": self.properties, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Connection": + return cls( + id=str(data["id"]), + source=Endpoint.from_dict(data["source"]), + target=Endpoint.from_dict(data["target"]), + name=str(data.get("name", "")), + properties=dict(data.get("properties", {})), + ) + + +@dataclass +class Graph: + blocks: dict[str, Component] = field(default_factory=dict) + connections: dict[str, Connection] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "blocks": [block.to_dict() for block in self.blocks.values()], + "connections": [connection.to_dict() for connection in self.connections.values()], + } + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "Graph": + data = data or {} + blocks = [Component.from_dict(item) for item in data.get("blocks", [])] + connections = [Connection.from_dict(item) for item in data.get("connections", [])] + if len({block.id for block in blocks}) != len(blocks): + raise ValueError("A graph contains duplicate component IDs") + if len({connection.id for connection in connections}) != len(connections): + raise ValueError("A graph contains duplicate connection IDs") + return cls( + blocks={block.id: block for block in blocks}, + connections={connection.id: connection for connection in connections}, + ) + + +@dataclass +class Component: + id: str + name: str + x: float = 0.0 + y: float = 0.0 + rotation: float = 0.0 + inputs: list[Port] = field(default_factory=list) + outputs: list[Port] = field(default_factory=list) + icon: Icon = field(default_factory=Icon) + properties: dict[str, Any] = field(default_factory=dict) + implementation_kind: str = "graph" + graph: Graph = field(default_factory=Graph) + source: dict[str, Any] = field(default_factory=dict) + show_subtree_in_library: bool = True + + def to_dict(self) -> dict[str, Any]: + implementation = {"kind": self.implementation_kind} + if self.implementation_kind == "text": + implementation["source"] = self.source + else: + implementation["graph"] = self.graph.to_dict() + return { + "id": self.id, + "name": self.name, + "position": {"x": self.x, "y": self.y}, + "rotation": self.rotation, + "interface": { + "inputs": [port.to_dict() for port in self.inputs], + "outputs": [port.to_dict() for port in self.outputs], + }, + "icon": self.icon.to_dict(), + "properties": self.properties, + "library": {"showSubtree": self.show_subtree_in_library}, + "implementation": implementation, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Component": + interface = data.get("interface", {}) + position = data.get("position", {}) + implementation = data["implementation"] + kind = str(implementation.get("kind", "graph")) + if kind not in {"graph", "text"}: + raise ValueError(f"Unknown component implementation kind: {kind}") + return cls( + id=str(data["id"]), + name=str(data.get("name", "Unnamed")), + x=float(position.get("x", 0.0)), + y=float(position.get("y", 0.0)), + rotation=float(data.get("rotation", 0.0)), + inputs=[Port.from_dict(item) for item in interface.get("inputs", [])], + outputs=[Port.from_dict(item) for item in interface.get("outputs", [])], + icon=Icon.from_dict(data.get("icon")), + properties=dict(data.get("properties", {})), + show_subtree_in_library=bool(data.get("library", {}).get("showSubtree", True)), + implementation_kind=kind, + graph=Graph.from_dict(implementation.get("graph")) if kind == "graph" else Graph(), + source=dict(implementation.get("source", {})) if kind == "text" else {}, + ) + + +@dataclass +class GraphDocument: + roots: dict[str, Component] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def empty(cls) -> "GraphDocument": + return cls(metadata={"name": "Untitled"}) + + def to_dict(self) -> dict[str, Any]: + return { + "format": "bedit-document", + "version": 1, + "metadata": self.metadata, + "roots": [root.to_dict() for root in self.roots.values()], + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "GraphDocument": + if data.get("format") != "bedit-document": + raise ValueError("This is not a BEdit document") + if data.get("version") != 1: + raise ValueError(f"Unsupported BEdit document version: {data.get('version')}") + roots = [Component.from_dict(item) for item in data["roots"]] + if len({root.id for root in roots}) != len(roots): + raise ValueError("The document contains duplicate root IDs") + document = cls( + roots={root.id: root for root in roots}, + metadata=dict(data.get("metadata", {})), + ) + document.validate() + return document + + def all_components(self): + def walk(component: Component): + yield component + if component.implementation_kind == "graph": + for child in component.graph.blocks.values(): + yield from walk(child) + + def all_roots(): + for root in self.roots.values(): + yield from walk(root) + + return all_roots() + + def find_component(self, component_id: str) -> Component | None: + return next( + (component for component in self.all_components() if component.id == component_id), + None, + ) + + def find_parent(self, component_id: str) -> Component | None: + for component in self.all_components(): + if component_id in component.graph.blocks: + return component + return None + + def validate(self) -> None: + seen: set[str] = set() + for component in self.all_components(): + if component.id in seen: + raise ValueError(f"Duplicate component ID: {component.id}") + seen.add(component.id) + if component.implementation_kind == "text" and component.graph.blocks: + raise ValueError(f"Text component {component.name} cannot contain a graph") + self._validate_graph(component) + + @staticmethod + def _validate_graph(owner: Component) -> None: + input_ids = {port.id for port in owner.inputs} + output_ids = {port.id for port in owner.outputs} + for connection in owner.graph.connections.values(): + if connection.source.interface is not None: + if connection.source.interface not in input_ids: + raise ValueError(f"Connection {connection.id} uses an unknown interface input") + else: + source = owner.graph.blocks.get(connection.source.block or "") + if source is None or connection.source.port not in {p.id for p in source.outputs}: + raise ValueError(f"Connection {connection.id} uses an unknown block output") + if connection.target.interface is not None: + if connection.target.interface not in output_ids: + raise ValueError(f"Connection {connection.id} uses an unknown interface output") + else: + target = owner.graph.blocks.get(connection.target.block or "") + if target is None or connection.target.port not in {p.id for p in target.inputs}: + raise ValueError(f"Connection {connection.id} uses an unknown block input") + + +def clone_component(source: Component) -> Component: + """Deep-copy a component tree and remap every owned object ID.""" + + def clone(current: Component) -> Component: + child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()] + child_ids = {old.id: new.id for old, new in child_pairs} + + def remap(endpoint: Endpoint) -> Endpoint: + if endpoint.interface is not None: + return endpoint + return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port) + + graph = Graph( + blocks={new.id: new for _old, new in child_pairs}, + connections={ + new_id: Connection( + new_id, + remap(connection.source), + remap(connection.target), + connection.name, + deepcopy(connection.properties), + ) + for connection in current.graph.connections.values() + for new_id in [str(uuid4())] + }, + ) + return Component( + id=str(uuid4()), + name=current.name, + x=current.x, + y=current.y, + inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs], + outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs], + icon=Icon(**current.icon.to_dict()), + properties=deepcopy(current.properties), + implementation_kind=current.implementation_kind, + graph=graph if current.implementation_kind == "graph" else Graph(), + source=deepcopy(current.source), + show_subtree_in_library=current.show_subtree_in_library, + ) + + return clone(source) diff --git a/BEdit/src/bedit/document/serializer.py b/BEdit/src/bedit/document/serializer.py new file mode 100644 index 0000000..b810360 --- /dev/null +++ b/BEdit/src/bedit/document/serializer.py @@ -0,0 +1,23 @@ +import json +from pathlib import Path + +from bedit.document.model import GraphDocument + + +class JsonDocumentSerializer: + @staticmethod + def load(path: Path) -> GraphDocument: + with path.open(encoding="utf-8") as file: + data = json.load(file) + if not isinstance(data, dict): + raise ValueError("The graph file must contain a JSON object") + return GraphDocument.from_dict(data) + + @staticmethod + def save(document: GraphDocument, path: Path) -> None: + temporary_path = path.with_suffix(path.suffix + ".tmp") + with temporary_path.open("w", encoding="utf-8") as file: + json.dump(document.to_dict(), file, indent=2) + file.write("\n") + temporary_path.replace(path) + diff --git a/BEdit/src/bedit/item_options_dialog.py b/BEdit/src/bedit/item_options_dialog.py new file mode 100644 index 0000000..5227c49 --- /dev/null +++ b/BEdit/src/bedit/item_options_dialog.py @@ -0,0 +1,42 @@ +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QLineEdit, + QMessageBox, + QVBoxLayout, +) + + +class ItemOptionsDialog(QDialog): + """Small, extensible options dialog shared by ports and connections.""" + + def __init__(self, title: str, name: str, parent=None, *, name_required: bool = True) -> None: + super().__init__(parent) + self.name_required = name_required + self.setWindowTitle(title) + self.resize(380, 120) + + layout = QVBoxLayout(self) + self.form = QFormLayout() + self.name_edit = QLineEdit(name, self) + self.form.addRow("Name:", self.name_edit) + layout.addLayout(self.form) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def name(self) -> str: + return self.name_edit.text().strip() + + def accept(self) -> None: + if self.name_required and not self.name: + QMessageBox.warning(self, "Invalid name", "The name cannot be empty.") + return + super().accept() diff --git a/BEdit/src/bedit/library/__init__.py b/BEdit/src/bedit/library/__init__.py new file mode 100644 index 0000000..a73536b --- /dev/null +++ b/BEdit/src/bedit/library/__init__.py @@ -0,0 +1,3 @@ +from bedit.library.repository import LibraryRepository, default_library_paths + +__all__ = ["LibraryRepository", "default_library_paths"] diff --git a/BEdit/src/bedit/library/repository.py b/BEdit/src/bedit/library/repository.py new file mode 100644 index 0000000..448a85a --- /dev/null +++ b/BEdit/src/bedit/library/repository.py @@ -0,0 +1,56 @@ +import json +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QObject, Signal + +from bedit.document.model import GraphDocument + + +@dataclass(frozen=True) +class LibraryDocument: + name: str + document: GraphDocument + source_path: str + + +def bundled_library_path() -> Path: + return Path(__file__).resolve().parents[1] / "data" / "libraries" / "example.json" + + +def default_library_paths() -> list[str]: + return [str(bundled_library_path())] + + +class LibraryRepository(QObject): + librariesChanged = Signal() + loadWarningsChanged = Signal(list) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.libraries: list[LibraryDocument] = [] + self.load_warnings: list[str] = [] + + def load_paths(self, paths: list[str]) -> None: + libraries: list[LibraryDocument] = [] + warnings: list[str] = [] + for raw_path in paths: + path = Path(raw_path).expanduser() + candidates = sorted(path.glob("*.json")) if path.is_dir() else [path] + for candidate in candidates: + try: + libraries.append(self._load_file(candidate)) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: + warnings.append(f"{candidate}: {error}") + self.libraries = libraries + self.load_warnings = warnings + self.librariesChanged.emit() + self.loadWarningsChanged.emit(warnings) + + @staticmethod + def _load_file(path: Path) -> LibraryDocument: + with path.open(encoding="utf-8") as file: + data = json.load(file) + document = GraphDocument.from_dict(data) + name = str(document.metadata.get("name") or path.stem) + return LibraryDocument(name, document, str(path)) diff --git a/BEdit/src/bedit/library/tree_model.py b/BEdit/src/bedit/library/tree_model.py new file mode 100644 index 0000000..3235adb --- /dev/null +++ b/BEdit/src/bedit/library/tree_model.py @@ -0,0 +1,91 @@ +import json + +from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, Qt, Signal +from PySide6.QtGui import QStandardItem, QStandardItemModel + +from bedit.document.controller import DocumentController +from bedit.document.model import Component +from bedit.library.repository import LibraryRepository + + +COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1 +COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2 +ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3 +COMPONENT_MIME_TYPE = "application/x-bedit-component" + + +class LibraryTreeModel(QStandardItemModel): + rebuilt = Signal() + def __init__( + self, + repository: LibraryRepository, + controller: DocumentController, + parent=None, + ) -> None: + super().__init__(parent) + self.repository = repository + self.controller = controller + repository.librariesChanged.connect(self.rebuild) + self.rebuild() + + def rebuild(self) -> None: + self.clear() + self.setHorizontalHeaderLabels(["Libraries"]) + for library in self.repository.libraries: + root = QStandardItem(library.name) + root.setDragEnabled(False) + root.setToolTip(library.source_path) + for component in library.document.roots.values(): + root.appendRow(self._component_item(component)) + self.appendRow(root) + self.rebuilt.emit() + + def _component_item(self, component: Component, current: bool = False) -> QStandardItem: + item = QStandardItem(component.name) + item.setEditable(False) + item.setData(component.to_dict(), COMPONENT_ROLE) + item.setData(component.id, COMPONENT_ID_ROLE) + item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE) + if component.show_subtree_in_library: + for child in component.graph.blocks.values(): + item.appendRow(self._component_item(child, current=current)) + return item + + def mimeTypes(self) -> list[str]: # noqa: N802 + return [COMPONENT_MIME_TYPE] + + def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802 + mime_data = QMimeData() + for index in indexes: + component = index.data(COMPONENT_ROLE) + if component: + encoded = json.dumps(component).encode("utf-8") + mime_data.setData(COMPONENT_MIME_TYPE, QByteArray(encoded)) + break + return mime_data + + def supportedDragActions(self): # noqa: N802 + return Qt.DropAction.CopyAction + + +class DocumentTreeModel(LibraryTreeModel): + def __init__(self, controller: DocumentController, parent=None) -> None: + QStandardItemModel.__init__(self, parent) + self.controller = controller + controller.documentReset.connect(self.rebuild) + controller.componentMoved.connect(lambda _component_id, _position: self.rebuild()) + controller.connectionAdded.connect(lambda _connection_id: self.rebuild()) + controller.connectionRemoved.connect(lambda _connection_id: self.rebuild()) + self.rebuild() + + def rebuild(self) -> None: + self.clear() + self.setHorizontalHeaderLabels(["Document"]) + if self.controller.document is not None: + current_root = QStandardItem("Current Document") + current_root.setDragEnabled(False) + current_root.setData("current-document", ITEM_KIND_ROLE) + for component in self.controller.document.roots.values(): + current_root.appendRow(self._component_item(component, current=True)) + self.appendRow(current_root) + self.rebuilt.emit() diff --git a/BEdit/src/bedit/main_window.py b/BEdit/src/bedit/main_window.py index 79325b3..b33ba77 100644 --- a/BEdit/src/bedit/main_window.py +++ b/BEdit/src/bedit/main_window.py @@ -1,13 +1,27 @@ +import json +from pathlib import Path + from PySide6.QtCore import QSettings, Qt, Slot from PySide6.QtGui import QCloseEvent -from PySide6.QtWidgets import QMainWindow, QMessageBox +from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox +from bedit.component_options_dialog import ComponentOptionsDialog +from bedit.document.controller import DocumentController +from bedit.document.model import Port +from bedit.item_options_dialog import ItemOptionsDialog +from bedit.library.repository import LibraryRepository +from bedit.library.tree_model import ( + COMPONENT_ID_ROLE, + ITEM_KIND_ROLE, + DocumentTreeModel, + LibraryTreeModel, +) from bedit.settings_dialog import SettingsDialog from bedit.ui_main_window import Ui_MainWindow class MainWindow(QMainWindow): - """Application shell for the window laid out in Qt Designer.""" + """Application shell and owner of the single active document.""" def __init__(self) -> None: super().__init__() @@ -15,44 +29,447 @@ class MainWindow(QMainWindow): self.ui.setupUi(self) self.settings = QSettings() + self.libraries = LibraryRepository(self) + self.document_controller = DocumentController(self) + self.library_tree_model = LibraryTreeModel( + self.libraries, + self.document_controller, + self, + ) + self.document_tree_model = DocumentTreeModel(self.document_controller, self) + self._configure_models() + self._connect_actions() self._populate_view_menu() - self.ui.actionExit.triggered.connect(self.close) - self.ui.actionSettings.triggered.connect(self.show_settings) - self.ui.actionAbout.triggered.connect(self.show_about) - self.ui.actionAboutQt.triggered.connect(lambda: QMessageBox.aboutQt(self, "About Qt Framework")) self._restore_window_geometry() + self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget) self.ui.leftDockHost.show() self.ui.panel_libraries.show() + self.ui.panel_document.show() + self.ui.leftDockHost.splitDockWidget( + self.ui.panel_document, + self.ui.panel_libraries, + Qt.Orientation.Vertical, + ) self.ui.workspaceSplitter.setSizes([280, 720]) + self.reload_libraries() + self._active_graph_changed() + self._update_title() + + def _configure_models(self) -> None: + self.ui.treeView.setModel(self.library_tree_model) + self.ui.treeView.setHeaderHidden(True) + self.ui.treeView.setDragEnabled(True) + self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly) + self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll) + self.ui.documentTreeView.setModel(self.document_tree_model) + self.ui.documentTreeView.setHeaderHidden(True) + self.ui.documentTreeView.setDragEnabled(True) + self.ui.documentTreeView.setDragDropMode( + self.ui.documentTreeView.DragDropMode.DragOnly + ) + self.ui.documentTreeView.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.ui.documentTreeView.customContextMenuRequested.connect( + self.show_library_context_menu + ) + self.ui.documentTreeView.doubleClicked.connect(self.activate_tree_component) + self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll) + self.ui.graphView.set_model(self.document_controller) + self.ui.graphView.componentOptionsRequested.connect(self.show_component_options) + self.ui.graphView.portOptionsRequested.connect(self.show_port_options) + self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options) + self.ui.graphView.selectionAvailabilityChanged.connect( + lambda _available: self._update_edit_actions() + ) + self.ui.navigateUpButton.clicked.connect(self.navigate_up) + self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer")) + self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input")) + self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output")) + self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer")) + self.ui.applyJsonButton.clicked.connect(self.apply_json) + self.document_controller.activeGraphChanged.connect(self._active_graph_changed) + + def _connect_actions(self) -> None: + self.ui.actionNew.triggered.connect(self.new_document) + self.ui.actionOpen.triggered.connect(self.open_document) + self.ui.actionSave.triggered.connect(self.save_document) + self.ui.actionSaveAs.triggered.connect(self.save_document_as) + self.ui.actionClose.triggered.connect(self.close_document) + self.ui.actionExit.triggered.connect(self.close) + self.ui.actionSettings.triggered.connect(self.show_settings) + self.ui.actionAbout.triggered.connect(self.show_about) + self.ui.actionAboutQt.triggered.connect( + lambda: QMessageBox.aboutQt(self, "About Qt Framework") + ) + self.ui.actionUndo.triggered.connect(self.document_controller.undo_stack.undo) + self.ui.actionRedo.triggered.connect(self.document_controller.undo_stack.redo) + self.ui.actionDelete.triggered.connect(self.ui.graphView.delete_selected) + self.ui.actionCopy.triggered.connect(self.ui.graphView.copy_selection) + self.ui.actionCut.triggered.connect(self.ui.graphView.cut_selection) + self.ui.actionPaste.triggered.connect(self.ui.graphView.paste_selection) + self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all) + self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected) + self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled) + self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled) + self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title()) + self.document_controller.filePathChanged.connect(lambda _path: self._update_title()) + self.document_controller.documentOpenedChanged.connect(self._document_opened_changed) + self.ui.actionUndo.setEnabled(False) + self.ui.actionRedo.setEnabled(False) + self._update_edit_actions() + self._document_opened_changed(False) def _populate_view_menu(self) -> None: - """Add checked show/hide actions for panels and toolbars.""" - panels = (self.ui.panel_libraries,) - for panel in panels: + for panel in (self.ui.panel_document, self.ui.panel_libraries): self.ui.menuPanels.addAction(panel.toggleViewAction()) - - toolbars = (self.ui.fileToolbar, self.ui.editToolbar) - for toolbar in toolbars: + for toolbar in ( + self.ui.fileToolbar, + self.ui.editToolbar, + self.ui.transformToolbar, + ): self.ui.menuToolbars.addAction(toolbar.toggleViewAction()) + def reload_libraries(self) -> None: + self.libraries.load_paths(SettingsDialog.library_paths(self.settings)) + self.ui.treeView.expandAll() + if self.libraries.load_warnings: + QMessageBox.warning( + self, + "Some libraries could not be loaded", + "\n".join(self.libraries.load_warnings), + ) + def _restore_window_geometry(self) -> None: geometry = self.settings.value("window/geometry") if geometry is not None: self.restoreGeometry(geometry) + def _update_title(self) -> None: + if self.document_controller.document is None: + self.setWindowTitle("BEdit") + return + name = self.document_controller.file_path.name if self.document_controller.file_path else "Untitled" + modified = "*" if not self.document_controller.undo_stack.isClean() else "" + self.setWindowTitle(f"{modified}{name} — BEdit") + + def _active_graph_changed(self) -> None: + component = self.document_controller.active_component + if component is None: + self.ui.graphBreadcrumbLabel.setText("No component selected") + self.ui.navigateUpButton.setEnabled(False) + self.ui.workspaceModeLabel.setText("") + self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage) + self.ui.applyJsonButton.setVisible(False) + for button in ( + self.ui.pointerToolButton, + self.ui.inputToolButton, + self.ui.outputToolButton, + ): + button.setVisible(False) + self._update_edit_actions() + return + self.ui.graphBreadcrumbLabel.setText(" › ".join(self.document_controller.breadcrumb())) + self.ui.navigateUpButton.setEnabled( + self.document_controller.document.find_parent(component.id) is not None + ) + is_graph = component.implementation_kind == "graph" + self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text") + self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage) + self.ui.applyJsonButton.setVisible(not is_graph) + for button in ( + self.ui.pointerToolButton, + self.ui.inputToolButton, + self.ui.outputToolButton, + ): + button.setVisible(is_graph) + if is_graph: + self.set_graph_tool("pointer") + else: + self._load_source_json() + self._update_edit_actions() + + def _update_edit_actions(self) -> None: + component = self.document_controller.active_component + is_graph = component is not None and component.implementation_kind == "graph" + has_selection = bool(self.ui.graphView.scene() and self.ui.graphView.scene().selectedItems()) + for action in (self.ui.actionCut, self.ui.actionCopy, self.ui.actionDelete): + action.setEnabled(is_graph and has_selection) + self.ui.actionRotateClockwise.setEnabled( + is_graph and self.ui.graphView.has_selected_components() + ) + self.ui.actionSelectAll.setEnabled(is_graph) + self.ui.actionPaste.setEnabled(is_graph) + + @Slot() + def navigate_up(self) -> None: + if self._resolve_source_edits(): + self.document_controller.navigate_up() + + def set_graph_tool(self, mode: str) -> None: + self.ui.graphView.set_tool_mode(mode) + buttons = { + "pointer": self.ui.pointerToolButton, + "input": self.ui.inputToolButton, + "output": self.ui.outputToolButton, + } + buttons[mode].setChecked(True) + + def _load_source_json(self) -> None: + component = self.document_controller.active_component + if component is None: + return + text = json.dumps( + { + "inputs": [port.to_dict() for port in component.inputs], + "outputs": [port.to_dict() for port in component.outputs], + "source": component.source, + }, + indent=2, + ) + self.ui.jsonEditor.setPlainText(text) + self.ui.jsonEditor.document().setModified(False) + + def _resolve_source_edits(self) -> bool: + component = self.document_controller.active_component + if ( + component is None + or component.implementation_kind != "text" + or not self.ui.jsonEditor.document().isModified() + ): + return True + answer = QMessageBox.question( + self, + "Apply text component changes?", + "The text component has unapplied input, output, or source changes.", + QMessageBox.StandardButton.Apply + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + if answer == QMessageBox.StandardButton.Apply: + return self.apply_json() + return answer == QMessageBox.StandardButton.Discard + + @Slot() + def apply_json(self) -> bool: + try: + data = json.loads(self.ui.jsonEditor.toPlainText()) + if not isinstance(data, dict): + raise ValueError("The text component JSON must be an object") + if not isinstance(data.get("inputs"), list): + raise ValueError("'inputs' must be a list") + if not isinstance(data.get("outputs"), list): + raise ValueError("'outputs' must be a list") + if not isinstance(data.get("source"), dict): + raise ValueError("'source' must be an object") + inputs = [Port.from_dict(item) for item in data["inputs"]] + outputs = [Port.from_dict(item) for item in data["outputs"]] + self.document_controller.replace_active_text_definition( + inputs, outputs, data["source"] + ) + except (TypeError, ValueError, json.JSONDecodeError) as error: + QMessageBox.critical(self, "Invalid text component JSON", str(error)) + return False + self._load_source_json() + return True + + def _maybe_save(self) -> bool: + if self.document_controller.document is None: + return True + if self.document_controller.undo_stack.isClean(): + return True + answer = QMessageBox.warning( + self, + "Unsaved changes", + "The current graph contains unsaved changes.", + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + if answer == QMessageBox.StandardButton.Save: + return self.save_document() + return answer == QMessageBox.StandardButton.Discard + + @Slot() + def new_document(self) -> None: + if self._resolve_source_edits() and self._maybe_save(): + self.document_controller.new_document() + + @Slot() + def close_document(self) -> None: + if self._resolve_source_edits() and self._maybe_save(): + self.document_controller.close_document() + + @Slot() + def open_document(self) -> None: + if not self._resolve_source_edits() or not self._maybe_save(): + return + filename, _ = QFileDialog.getOpenFileName( + self, "Open graph", "", "BEdit graphs (*.bedit.json *.json);;All files (*)" + ) + if not filename: + return + try: + self.document_controller.load(Path(filename)) + except (OSError, ValueError) as error: + QMessageBox.critical(self, "Could not open graph", str(error)) + + @Slot() + def save_document(self) -> bool: + if self.document_controller.document is None: + return False + if self.document_controller.file_path is None: + return self.save_document_as() + try: + self.document_controller.save() + except OSError as error: + QMessageBox.critical(self, "Could not save graph", str(error)) + return False + return True + + @Slot() + def save_document_as(self) -> bool: + if self.document_controller.document is None: + return False + filename, _ = QFileDialog.getSaveFileName( + self, + "Save graph", + "untitled.bedit.json", + "BEdit graphs (*.bedit.json);;JSON files (*.json);;All files (*)", + ) + if not filename: + return False + try: + self.document_controller.save(Path(filename)) + except OSError as error: + QMessageBox.critical(self, "Could not save graph", str(error)) + return False + return True + @Slot() def show_settings(self) -> None: - SettingsDialog(self).exec() + dialog = SettingsDialog(self) + dialog.settingsChanged.connect(self.reload_libraries) + dialog.exec() + + def _document_opened_changed(self, opened: bool) -> None: + for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs): + action.setEnabled(opened) + self._active_graph_changed() + + @Slot(object) + def activate_tree_component(self, index) -> None: + if index.data(ITEM_KIND_ROLE) != "current-component": + return + component_id = index.data(COMPONENT_ID_ROLE) + if component_id: + self.document_controller.activate_component(component_id) + + @Slot(object) + def show_library_context_menu(self, position) -> None: + tree_view = self.ui.documentTreeView + index = tree_view.indexAt(position) + kind = index.data(ITEM_KIND_ROLE) + if kind == "current-component": + component_id = index.data(COMPONENT_ID_ROLE) + document = self.document_controller.document + component = document.find_component(component_id) if document else None + if component is None: + return + menu = QMenu(self) + graph_action = None + text_action = None + if component.implementation_kind == "graph": + graph_action = menu.addAction("New Graph Block") + text_action = menu.addAction("New Text Block") + menu.addSeparator() + options_action = menu.addAction("Component Options…") + delete_action = menu.addAction("Delete") + selected = menu.exec(tree_view.viewport().mapToGlobal(position)) + if selected is graph_action: + self.document_controller.add_child(component_id, "graph") + elif selected is text_action: + self.document_controller.add_child(component_id, "text") + elif selected is options_action: + self.show_component_options(component_id) + elif selected is delete_action: + answer = QMessageBox.question( + self, + "Delete component?", + f"Delete {component.name!r} and all of its contents?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if answer == QMessageBox.StandardButton.Yes: + self.document_controller.delete_component(component_id) + return + if kind != "current-document": + return + menu = QMenu(self) + graph_action = menu.addAction("New Graph Block") + text_action = menu.addAction("New Text Block") + selected = menu.exec(tree_view.viewport().mapToGlobal(position)) + if selected is graph_action: + self.document_controller.add_root("graph") + elif selected is text_action: + self.document_controller.add_root("text") + + @Slot(str) + def show_component_options(self, component_id: str) -> None: + document = self.document_controller.document + component = document.find_component(component_id) if document else None + if component is None: + return + dialog = ComponentOptionsDialog(component, self) + if dialog.exec() == dialog.DialogCode.Accepted: + self.document_controller.edit_component_appearance( + component_id, + dialog.ui.nameEdit.text().strip(), + dialog.ui.shapeCombo.currentText(), + dialog.ui.fillEdit.text(), + dialog.ui.borderEdit.text(), + dialog.ui.iconTextEdit.text(), + dialog.ui.showSubtreeCheckBox.isChecked(), + ) + + @Slot(str, str) + def show_port_options(self, port_id: str, direction: str) -> None: + owner = self.document_controller.active_component + if owner is None: + return + ports = owner.inputs if direction == "input" else owner.outputs + port = next((item for item in ports if item.id == port_id), None) + if port is None: + return + dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self) + if dialog.exec() == dialog.DialogCode.Accepted: + self.document_controller.rename_interface_port(port_id, dialog.name) + + @Slot(str) + def show_connection_options(self, connection_id: str) -> None: + owner = self.document_controller.active_component + if owner is None or owner.implementation_kind != "graph": + return + connection = owner.graph.connections.get(connection_id) + if connection is None: + return + dialog = ItemOptionsDialog( + "Connection Options", connection.name, self, name_required=False + ) + if dialog.exec() == dialog.DialogCode.Accepted: + self.document_controller.rename_connection(connection_id, dialog.name) @Slot() def show_about(self) -> None: QMessageBox.about( self, "About BEdit", - "

BEdit

A starter desktop application built with Python and Qt.

", + "

BEdit

A graphical editor built with Python and Qt.

", ) def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name) + if not self._resolve_source_edits() or not self._maybe_save(): + event.ignore() + return self.settings.setValue("window/geometry", self.saveGeometry()) event.accept() diff --git a/BEdit/src/bedit/resources_rc.py b/BEdit/src/bedit/resources_rc.py index f652626..915731e 100644 --- a/BEdit/src/bedit/resources_rc.py +++ b/BEdit/src/bedit/resources_rc.py @@ -234,6 +234,114 @@ _H\xaed[3B\xe2\xfa3\xb9\x04\xc0\xec\xee\xee\ \xe2\xbf\x0f\x0f\x0f\xa7)sR\xf6\xffA\x01\x8e9D\ R\xff\x03\x8c\xc5\xeaCX+lK\x00\x00\x00\x00I\ END\xaeB`\x82\ +\x00\x00\x06\x9c\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\ +\x00\x00\x09pHYs\x00\x00\x06\xec\x00\x00\x06\xec\x01\ +\x1eu85\x00\x00\x00\x07tIME\x07\xd9\x06\x11\ +\x17\x13$\xda\x852\xab\x00\x00\x06\x1cIDATx\ +\xda\xed\x95}\x8c\x15\xd5\x19\xc6\x9f\xf7\xcc\xdc;\xb3w\ +?\xfd\xa0T\xd8\x22+$(\xf2\xd5j\xc2\xae\x1f$\ +\xadI\xff\xd0\xb8J(1%\x9a\x08%\xd6\x15-\xb6\ +\x8d\xb6\xb1]H\x0bX\xa2\xaeUB\xac\xa1\xa9\xd1H\ +\x1b\x14\xa4F\xa0\xc8*\x9a\x0a\xbaT\x5cL\x88\xc9\x92\ + \x88\xd4\x82Z\xe1\xde;w\xef\x9d\xef\xf3\xf6\xcc\xb9\ +\xc3d7J\x1a\xdc\xb4\x7f\xf1K\xde\xfb\xce\x99\xb9\x93\ +\xe79\xef{\xce\x19\x9c\xe7=\xe5c^\x87\xaf_\xa3\xc4(\x11\ +\xb2\xff%m2\x81\x7f\x96\x0d~\xf3p\x8e\xaa\xa1\xd8\ +\xdcd\xc8;?*\x81\x1b-^.@\xab\x0d\x81\xa2\ +m\x19\x13\xcb\xc3Q\xb0z\xbe}\xb6\x16de\xcf\x22\ +\x82\x81\x1d\x07\x19]\xdfr\x11K\x80\xa1\x82\xb9\x9e\xb3\ +kF\x18\x13&4KZ<7D\xd7\xa5\xe1\xc2/\ +\x5c\xae\xb6\xd9\xf8\xf9o\xbb\xed\xb5\x15_\xb6\x97\xdd\xd8\ +<\xe5D\xbbS\xf1/\x1b0\x05\xa3\xe4%36\xb5\ +\xb0\xa9\xb2m\x99xa?\xe3\xdaI5P\x22\xc3\xf5\ +\xd0\xf0\xe8\x05L\xa4\x7f\x10\xc5\xc0w\xda\x19?\xfb.\ +c\xca8^\xf5\xc0\x16\xafT0E\x87\xe3\xe1\x1a\xc7\ +\xe3\xeb~\xf2Bp\xf9(\x03M\xcb\x8a\xc0\xb5\x5cX\ +\xf4\xf2\xd4\xae\x9f\xbe\x18s\x83\xa5\xc5\xb5\x89\x13e\x81\ +\xfd\xc7\x02\x5c5\xc1\x83\xcc\xc4\xd2\x0c\xceL\x10\x01`\ +\xca\xae\x19\x04\x10c\xfe,\x89\x9e\xeb\xd1R\xf6y\xfb\ +\x86;\x0aC\x8e\xcb'\xca5y\xeb(\x03\xcd\xb6\xe8\ +\x1cwu\xc9q|\x93\xb7\xbe\x1f\xd3\xc7\xa7\x05\xcc\xb4\ +\xfc}\xfd\x01\xe6O\xaf\x22\x88\xb5\xb0\x16\xcdLh\x11\ +\xa4P6\x94\xcc*$H\xb7F\xe0\xe8\xbf\xa1\xde\xa7\ +\xbdPT|\x0c*\x13\x933\x03W\xfc\xba\xf4@S\ +\x1e\x03-6\x19M6\xd1\xf8V\xc2\x83/\x05 !\ +\xf0\xf6\x87\x11\x02\xdfCG\x9b\x0f)\xf9\x8c\x03 3\ +\xc1YH)\x11\xc7*T\x06\xa7\x0b\xd4\x10\x80 l\ +|7\xa6r\x85\xef\xd6\x06j\x1cW\x8f\xd5\x96\x09\xb0\xf0\xca\x12j~\x8c0\ +Tf\xd2\x08\x93\x88\x22%\x18kQ\xc9\x5c\x9f\xbd\xde\ +\xc2\x89p\xfd@\xcb+\x03\xcf\x0dH\x0c{\xe8\xfb\xe0\ +dxb\xe6\xca\xf2\xec\x92';\x9c\x9a<\x06\xc5\xf7\ +\xfb\x9c\x1cA\xd1\xb9\xc6\x81iRO\xd5\x8d\xd6\x1fX\ +\xd9&\xfe\xb4\xc7\xc7\xde\xc3\x11&4\xd6\xb0hv\x05\ +L\x02\x86\xde\x92\x86\xfe\x10\x09C\x8dI\x8d\xd3kS\ +\xdd'\x91\xe4\xfaX\x90\xd0\xff+\xba\xc4K\x9e\x8f\xbd\ +]\xf77\x16&\xff\xc2yN@\xde\xa1\x1e\x07\xea\xf9\ +6\xd3\xc4\x1c\x83\xd8\x17P\xec\xfbUK\xb2\x0e\x9a/\ +n\xe4\x00\x8aE\x9dy\x1c\xfc$\xc6m3\x87!\x91\ +\x8a\x89D\x10Z\x88\xce\x88\x8b\xfa3\xaaW\xba~_\ +\x9b%4X\x02\x0f\xef\x94Dd,\x81\xc2\xf1\xc5\xbd\ +\x8e\x07\xaax\x9cW-\xf8A\xa5&O\x1e|\xa70\ +C \xa5\xb9A\xb8\x0dy\xc3\x84\xe2\xc1\xcd\x9e\x9ay\ +U\x09@\x8b\x90\xa1\x82(5\xa2\xb2\x00H\x0b\xd7\xc7\ +\x02i\x85\xf4\xfd\xfa;{\x8f0\xef\xff8\x1ezu\ +\xb9\xbd\x09\x8a\xd3O4U\xaa>\xd69.h8\xa0\ +U\xc7\x1fk\x9b7q\x96\xd2@J\x8b-\x06\x0b\x0d\ +9\x13`\xde\xfb\xa1\xc45\x93\x036\xcdd\x86\x80H\ +\x221\x91\x1d\xcf\xa9\x11\x8c0\x92\x1d\xdb\x02\xb6\x05\xf4\ +\xbe\x1c\x12Ic\xe1u\x8f8Hh\xb8\xbb\x08\x90x\ +\x88\xc9\xe8v\xd6\xb7\xae\x80\xe2_}m \x8c\xe0\xfe\ +M._H\x9f\x1f\xbf\x98>s~\x7f`\xca\x8c\x9b\ +\xa7\xc7Xv\xbd\x0fF:S#\xc9F\xd6\x7f\xf3\x8c\ +\x11#\x19\x1b\xba\xfcy\x83\xf0\xcc@\x8c'^\x8f^\ +<\xb4\xa6\xe56\xd4\xc9L\xb8O_p\xf6\x8fQ\xc1\ +6\xef\x1d6\xc7O:\x84\xab\xfe|x\xedE\xb4\xf1\ +}\xabw\xde\xfaf\xec:\x94C\xb3\x9d\xf6W\x97\x18\ +i\xefU\xd4\x17]Z% \x90\x845;B\x84\xd2\ +X<\xf3\x97%\x8c \x15\x1f\x0d\x01\xa3Y\xbd=\xf8\ +\xa3\x04\x96J)\xff!\x88~\xb3b\xbb\xf3\xf7\x82\xc8\ +={\xd98^\xb8nA\xccs\xda\x89B\xd6\x0b\xad\ +.\xac\x0f\xacz\xf9\xd5\x96\xc6\x8f7\x86\xd8vP\xf6\ +\x9e\xeck^\x8dse\xedN_\xe7\xbe\xd7\xa3\xee\xc7\ +\xfa\x83\xc1Gv\x05\xbc\xe2\xafANW\xe7\x9e\xf2\x0c\ +,.\x0du?Y\xe4\xe3'K\xb2\x5cv\xb8\xe8\x0c\ +se\xb8\xca\xd5\x9a\xc7\x9e\x17\xf0\xd0'\xbe4\x96\x16\ +K\x18+\x8f\xf7\xd7\x8d<\xbdGN\xca\xda\xd3S\xd4\ +\xd9\xee\xa9\xfc\x10?*\xd5\x1e\xda\xe2p\xe0)q\xd7\ +\xe5\x9a\xe73s\xc8\xb3W:l\xf7\x94\xbb\xf1\x17\xc6\ +\xff\x1c\xf3.\xe7wm\xf7\x95x\xf3\xbbU\x99\x88o\ +\xd9\xefJ,)\x0e\xe2\x1c!\x8c\x81\x5cO\xe9\x82\xd0\ +\xe3\x8dWw\x187\x1e\xfdB\xc2\x8d1\x15\xa1<\xe2\ +n\xb8\x10\xff\x17\x1a\x96\x9e\xd2\xd9Z\xe6L-\xdc\xe3\ +\x8c\xc7y\xbe\x06\xff\x01\xd8\x02\xdah\xfa;i$\x00\ +\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x07\xe4\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -906,6 +1014,11 @@ qt_resource_name = b"\ \x00d\ \x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00-\x00a\x00s\x00.\ \x00p\x00n\x00g\ +\x00\x14\ +\x0c\xbb2g\ +\x00t\ +\x00r\x00a\x00n\x00s\x00f\x00o\x00r\x00m\x00-\x00r\x00o\x00t\x00a\x00t\x00e\x00.\ +\x00p\x00n\x00g\ \x00\x0d\ \x03\xd2\xbeg\ \x00e\ @@ -943,25 +1056,27 @@ qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x00\x00\x02\x00\x00\x00\x09\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0a\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00\x19Z\ +\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xfa\ \x00\x00\x01\x9f{C\xf1'\ -\x00\x00\x00\xe2\x00\x00\x00\x00\x00\x01\x00\x00$\x8c\ +\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00+,\ \x00\x00\x01\x9f{0\xc99\ -\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\ +\x00\x00\x00\x92\x00\x00\x00\x00\x00\x01\x00\x00\x14\x92\ \x00\x00\x01\x9f{C\xf1\x18\ -\x00\x00\x00\x84\x00\x00\x00\x00\x00\x01\x00\x00\x15\xda\ +\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x1cz\ \x00\x00\x01\x9f{C\xf1.\ \x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\ \x00\x00\x01\x9f{0\xc9B\ -\x00\x00\x01\x0a\x00\x00\x00\x00\x00\x01\x00\x00+\x96\ +\x00\x00\x018\x00\x00\x00\x00\x00\x01\x00\x0026\ \x00\x00\x01\x9f{C\xf1N\ +\x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xf2\ +\x00\x00\x01\x9f{\x8d\xf34\ \x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x01\x9f{0\xc9+\ -\x00\x00\x00\xc2\x00\x00\x00\x00\x00\x01\x00\x00\x1c\xba\ +\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00#Z\ \x00\x00\x01\x9f{C\xf1=\ -\x00\x00\x01,\x00\x00\x00\x00\x00\x01\x00\x001\x18\ +\x00\x00\x01Z\x00\x00\x00\x00\x00\x01\x00\x007\xb8\ \x00\x00\x01\x9f{0\xc9R\ " diff --git a/BEdit/src/bedit/settings_dialog.py b/BEdit/src/bedit/settings_dialog.py index f557fc7..82f5c3f 100644 --- a/BEdit/src/bedit/settings_dialog.py +++ b/BEdit/src/bedit/settings_dialog.py @@ -1,17 +1,26 @@ -from PySide6.QtCore import QSettings -from PySide6.QtWidgets import QDialog +from pathlib import Path +from PySide6.QtCore import QSettings, Signal +from PySide6.QtWidgets import QDialog, QFileDialog + +from bedit.library.repository import default_library_paths from bedit.ui_settings_dialog import Ui_SettingsDialog class SettingsDialog(QDialog): """Edit application preferences defined in the Designer form.""" + settingsChanged = Signal() + def __init__(self, parent=None) -> None: super().__init__(parent) self.ui = Ui_SettingsDialog() self.ui.setupUi(self) self.settings = QSettings() + self.ui.addLibraryFileButton.clicked.connect(self._add_library_file) + self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder) + self.ui.removeLibraryPathButton.clicked.connect(self._remove_library_path) + self.ui.libraryPathsList.itemSelectionChanged.connect(self._update_remove_button) self._load_settings() def _load_settings(self) -> None: @@ -21,10 +30,59 @@ class SettingsDialog(QDialog): self.ui.autosaveIntervalSpinBox.setValue( self.settings.value("general/autosaveInterval", 5, type=int) ) + self.ui.libraryPathsList.clear() + self.ui.libraryPathsList.addItems(self.library_paths(self.settings)) + self._update_remove_button() + + @staticmethod + def library_paths(settings: QSettings | None = None) -> list[str]: + settings = settings or QSettings() + value = settings.value("libraries/paths", default_library_paths()) + if isinstance(value, str): + return [value] + return [str(path) for path in value] + + def _add_library_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + "Add library", + "", + "BEdit libraries (*.json);;All files (*)", + ) + if path: + self._append_unique_path(path) + + def _add_library_folder(self) -> None: + path = QFileDialog.getExistingDirectory(self, "Add library folder") + if path: + self._append_unique_path(path) + + def _append_unique_path(self, path: str) -> None: + normalized = str(Path(path).expanduser().resolve()) + existing = { + self.ui.libraryPathsList.item(row).text() + for row in range(self.ui.libraryPathsList.count()) + } + if normalized not in existing: + self.ui.libraryPathsList.addItem(normalized) + + def _remove_library_path(self) -> None: + for item in self.ui.libraryPathsList.selectedItems(): + self.ui.libraryPathsList.takeItem(self.ui.libraryPathsList.row(item)) + + def _update_remove_button(self) -> None: + self.ui.removeLibraryPathButton.setEnabled(bool(self.ui.libraryPathsList.selectedItems())) def accept(self) -> None: self.settings.setValue("general/autosaveEnabled", self.ui.autosaveGroupBox.isChecked()) self.settings.setValue( "general/autosaveInterval", self.ui.autosaveIntervalSpinBox.value() ) + paths = [ + self.ui.libraryPathsList.item(row).text() + for row in range(self.ui.libraryPathsList.count()) + ] + self.settings.setValue("libraries/paths", paths) + self.settings.sync() + self.settingsChanged.emit() super().accept() diff --git a/BEdit/src/bedit/ui_component_options_dialog.py b/BEdit/src/bedit/ui_component_options_dialog.py new file mode 100644 index 0000000..f415f8d --- /dev/null +++ b/BEdit/src/bedit/ui_component_options_dialog.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'component_options_dialog.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 (QAbstractButton, QApplication, QCheckBox, QComboBox, + QDialog, QDialogButtonBox, QFormLayout, QLabel, + QLineEdit, QSizePolicy, QSpacerItem, QVBoxLayout, + QWidget) + +class Ui_ComponentOptionsDialog(object): + def setupUi(self, ComponentOptionsDialog): + if not ComponentOptionsDialog.objectName(): + ComponentOptionsDialog.setObjectName(u"ComponentOptionsDialog") + ComponentOptionsDialog.resize(420, 260) + self.dialogLayout = QVBoxLayout(ComponentOptionsDialog) + self.dialogLayout.setObjectName(u"dialogLayout") + self.optionsForm = QFormLayout() + self.optionsForm.setObjectName(u"optionsForm") + self.nameLabel = QLabel(ComponentOptionsDialog) + self.nameLabel.setObjectName(u"nameLabel") + + self.optionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel) + + self.nameEdit = QLineEdit(ComponentOptionsDialog) + self.nameEdit.setObjectName(u"nameEdit") + + self.optionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit) + + self.shapeLabel = QLabel(ComponentOptionsDialog) + self.shapeLabel.setObjectName(u"shapeLabel") + + self.optionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.shapeLabel) + + self.shapeCombo = QComboBox(ComponentOptionsDialog) + self.shapeCombo.addItem("") + self.shapeCombo.addItem("") + self.shapeCombo.setObjectName(u"shapeCombo") + + self.optionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.shapeCombo) + + self.iconTextLabel = QLabel(ComponentOptionsDialog) + self.iconTextLabel.setObjectName(u"iconTextLabel") + + self.optionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.iconTextLabel) + + self.iconTextEdit = QLineEdit(ComponentOptionsDialog) + self.iconTextEdit.setObjectName(u"iconTextEdit") + + self.optionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.iconTextEdit) + + self.fillLabel = QLabel(ComponentOptionsDialog) + self.fillLabel.setObjectName(u"fillLabel") + + self.optionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.fillLabel) + + self.fillEdit = QLineEdit(ComponentOptionsDialog) + self.fillEdit.setObjectName(u"fillEdit") + + self.optionsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.fillEdit) + + self.borderLabel = QLabel(ComponentOptionsDialog) + self.borderLabel.setObjectName(u"borderLabel") + + self.optionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.borderLabel) + + self.borderEdit = QLineEdit(ComponentOptionsDialog) + self.borderEdit.setObjectName(u"borderEdit") + + self.optionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.borderEdit) + + self.showSubtreeCheckBox = QCheckBox(ComponentOptionsDialog) + self.showSubtreeCheckBox.setObjectName(u"showSubtreeCheckBox") + self.showSubtreeCheckBox.setChecked(True) + + self.optionsForm.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.showSubtreeCheckBox) + + + self.dialogLayout.addLayout(self.optionsForm) + + self.optionsSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.dialogLayout.addItem(self.optionsSpacer) + + self.buttonBox = QDialogButtonBox(ComponentOptionsDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) + + self.dialogLayout.addWidget(self.buttonBox) + + + self.retranslateUi(ComponentOptionsDialog) + self.buttonBox.accepted.connect(ComponentOptionsDialog.accept) + self.buttonBox.rejected.connect(ComponentOptionsDialog.reject) + + QMetaObject.connectSlotsByName(ComponentOptionsDialog) + # setupUi + + def retranslateUi(self, ComponentOptionsDialog): + ComponentOptionsDialog.setWindowTitle(QCoreApplication.translate("ComponentOptionsDialog", u"Component Options", None)) + self.nameLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Name:", None)) + self.shapeLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon shape:", None)) + self.shapeCombo.setItemText(0, QCoreApplication.translate("ComponentOptionsDialog", u"rectangle", None)) + self.shapeCombo.setItemText(1, QCoreApplication.translate("ComponentOptionsDialog", u"ellipse", None)) + + self.iconTextLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Icon text:", None)) + self.fillLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Fill color:", None)) + self.fillEdit.setPlaceholderText(QCoreApplication.translate("ComponentOptionsDialog", u"#dbeafe", None)) + self.borderLabel.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Border color:", None)) + self.borderEdit.setPlaceholderText(QCoreApplication.translate("ComponentOptionsDialog", u"#303030", None)) + self.showSubtreeCheckBox.setText(QCoreApplication.translate("ComponentOptionsDialog", u"Show contained components in the Libraries tree", None)) + # retranslateUi + diff --git a/BEdit/src/bedit/ui_main_window.py b/BEdit/src/bedit/ui_main_window.py index e41f54e..1f447f3 100644 --- a/BEdit/src/bedit/ui_main_window.py +++ b/BEdit/src/bedit/ui_main_window.py @@ -16,10 +16,13 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient, QIcon, QImage, QKeySequence, QLinearGradient, QPainter, QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QApplication, QDockWidget, QHBoxLayout, QHeaderView, - QMainWindow, QMenu, QMenuBar, QSizePolicy, - QSplitter, QToolBar, QTreeView, QVBoxLayout, - QWidget) +from PySide6.QtWidgets import (QApplication, QDockWidget, QFrame, QHBoxLayout, + QHeaderView, QLabel, QMainWindow, QMenu, + QMenuBar, QPlainTextEdit, QPushButton, QSizePolicy, + QSpacerItem, QSplitter, QStackedWidget, QToolBar, + QToolButton, QTreeView, QVBoxLayout, QWidget) + +from bedit.workspace.view import GraphWorkspaceView from . import resources_rc class Ui_MainWindow(object): @@ -32,50 +35,59 @@ class Ui_MainWindow(object): icon = QIcon() icon.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) self.actionNew.setIcon(icon) + self.actionRotateClockwise = QAction(MainWindow) + self.actionRotateClockwise.setObjectName(u"actionRotateClockwise") + icon1 = QIcon() + icon1.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionRotateClockwise.setIcon(icon1) self.actionOpen = QAction(MainWindow) self.actionOpen.setObjectName(u"actionOpen") - icon1 = QIcon() - icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionOpen.setIcon(icon1) + icon2 = QIcon() + icon2.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionOpen.setIcon(icon2) self.actionSave = QAction(MainWindow) self.actionSave.setObjectName(u"actionSave") - icon2 = QIcon() - icon2.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionSave.setIcon(icon2) + icon3 = QIcon() + icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionSave.setIcon(icon3) self.actionSaveAs = QAction(MainWindow) self.actionSaveAs.setObjectName(u"actionSaveAs") - icon3 = QIcon() - icon3.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionSaveAs.setIcon(icon3) + icon4 = QIcon() + icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionSaveAs.setIcon(icon4) self.actionExit = QAction(MainWindow) self.actionExit.setObjectName(u"actionExit") + self.actionClose = QAction(MainWindow) + self.actionClose.setObjectName(u"actionClose") self.actionUndo = QAction(MainWindow) self.actionUndo.setObjectName(u"actionUndo") - icon4 = QIcon() - icon4.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionUndo.setIcon(icon4) + icon5 = QIcon() + icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionUndo.setIcon(icon5) self.actionRedo = QAction(MainWindow) self.actionRedo.setObjectName(u"actionRedo") - icon5 = QIcon() - icon5.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionRedo.setIcon(icon5) + icon6 = QIcon() + icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionRedo.setIcon(icon6) self.actionCut = QAction(MainWindow) self.actionCut.setObjectName(u"actionCut") - icon6 = QIcon() - icon6.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionCut.setIcon(icon6) + icon7 = QIcon() + icon7.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionCut.setIcon(icon7) self.actionCopy = QAction(MainWindow) self.actionCopy.setObjectName(u"actionCopy") - icon7 = QIcon() - icon7.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionCopy.setIcon(icon7) + icon8 = QIcon() + icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionCopy.setIcon(icon8) self.actionPaste = QAction(MainWindow) self.actionPaste.setObjectName(u"actionPaste") - icon8 = QIcon() - icon8.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) - self.actionPaste.setIcon(icon8) + icon9 = QIcon() + icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionPaste.setIcon(icon9) self.actionSelectAll = QAction(MainWindow) self.actionSelectAll.setObjectName(u"actionSelectAll") + self.actionDelete = QAction(MainWindow) + self.actionDelete.setObjectName(u"actionDelete") self.actionAbout = QAction(MainWindow) self.actionAbout.setObjectName(u"actionAbout") self.actionSettings = QAction(MainWindow) @@ -118,6 +130,24 @@ class Ui_MainWindow(object): self.panel_libraries.setWidget(self.dockWidgetContents) self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_libraries) + self.panel_document = QDockWidget(self.leftDockHost) + self.panel_document.setObjectName(u"panel_document") + self.panel_document.setMinimumSize(QSize(220, 91)) + self.documentDockContents = QWidget() + self.documentDockContents.setObjectName(u"documentDockContents") + self.documentPanelLayout = QVBoxLayout(self.documentDockContents) + self.documentPanelLayout.setSpacing(0) + self.documentPanelLayout.setObjectName(u"documentPanelLayout") + self.documentPanelLayout.setContentsMargins(0, 0, 0, 0) + self.documentTreeView = QTreeView(self.documentDockContents) + self.documentTreeView.setObjectName(u"documentTreeView") + self.documentTreeView.setAlternatingRowColors(True) + self.documentTreeView.setUniformRowHeights(True) + + self.documentPanelLayout.addWidget(self.documentTreeView) + + self.panel_document.setWidget(self.documentDockContents) + self.leftDockHost.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_document) self.workspaceSplitter.addWidget(self.leftDockHost) self.workspace = QWidget(self.workspaceSplitter) self.workspace.setObjectName(u"workspace") @@ -126,9 +156,107 @@ class Ui_MainWindow(object): sizePolicy1.setVerticalStretch(0) sizePolicy1.setHeightForWidth(self.workspace.sizePolicy().hasHeightForWidth()) self.workspace.setSizePolicy(sizePolicy1) - self.workspace.setStyleSheet(u"QWidget#workspace {\n" -" background-color: rgb(198, 198, 198);\n" -"}") + self.workspaceEditorLayout = QVBoxLayout(self.workspace) + self.workspaceEditorLayout.setSpacing(0) + self.workspaceEditorLayout.setObjectName(u"workspaceEditorLayout") + self.workspaceEditorLayout.setContentsMargins(0, 0, 0, 0) + self.workspaceHeader = QFrame(self.workspace) + self.workspaceHeader.setObjectName(u"workspaceHeader") + self.workspaceHeader.setMinimumSize(QSize(0, 34)) + self.workspaceHeader.setMaximumSize(QSize(16777215, 34)) + self.workspaceHeader.setFrameShape(QFrame.Shape.StyledPanel) + self.workspaceHeaderLayout = QHBoxLayout(self.workspaceHeader) + self.workspaceHeaderLayout.setObjectName(u"workspaceHeaderLayout") + self.workspaceHeaderLayout.setContentsMargins(6, 2, 6, 2) + self.navigateUpButton = QToolButton(self.workspaceHeader) + self.navigateUpButton.setObjectName(u"navigateUpButton") + + self.workspaceHeaderLayout.addWidget(self.navigateUpButton) + + self.graphBreadcrumbLabel = QLabel(self.workspaceHeader) + self.graphBreadcrumbLabel.setObjectName(u"graphBreadcrumbLabel") + + self.workspaceHeaderLayout.addWidget(self.graphBreadcrumbLabel) + + self.workspaceModeLabel = QLabel(self.workspaceHeader) + self.workspaceModeLabel.setObjectName(u"workspaceModeLabel") + + self.workspaceHeaderLayout.addWidget(self.workspaceModeLabel) + + self.workspaceHeaderSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.workspaceHeaderLayout.addItem(self.workspaceHeaderSpacer) + + self.applyJsonButton = QPushButton(self.workspaceHeader) + self.applyJsonButton.setObjectName(u"applyJsonButton") + self.applyJsonButton.setVisible(False) + + self.workspaceHeaderLayout.addWidget(self.applyJsonButton) + + self.pointerToolButton = QToolButton(self.workspaceHeader) + self.pointerToolButton.setObjectName(u"pointerToolButton") + self.pointerToolButton.setCheckable(True) + self.pointerToolButton.setChecked(True) + self.pointerToolButton.setAutoExclusive(True) + + self.workspaceHeaderLayout.addWidget(self.pointerToolButton) + + self.inputToolButton = QToolButton(self.workspaceHeader) + self.inputToolButton.setObjectName(u"inputToolButton") + self.inputToolButton.setCheckable(True) + self.inputToolButton.setAutoExclusive(True) + + self.workspaceHeaderLayout.addWidget(self.inputToolButton) + + self.outputToolButton = QToolButton(self.workspaceHeader) + self.outputToolButton.setObjectName(u"outputToolButton") + self.outputToolButton.setCheckable(True) + self.outputToolButton.setAutoExclusive(True) + + self.workspaceHeaderLayout.addWidget(self.outputToolButton) + + + self.workspaceEditorLayout.addWidget(self.workspaceHeader) + + self.workspaceStack = QStackedWidget(self.workspace) + self.workspaceStack.setObjectName(u"workspaceStack") + self.graphPage = QWidget() + self.graphPage.setObjectName(u"graphPage") + self.graphPageLayout = QVBoxLayout(self.graphPage) + self.graphPageLayout.setObjectName(u"graphPageLayout") + self.graphPageLayout.setContentsMargins(0, 0, 0, 0) + self.graphView = GraphWorkspaceView(self.graphPage) + self.graphView.setObjectName(u"graphView") + + self.graphPageLayout.addWidget(self.graphView) + + self.workspaceStack.addWidget(self.graphPage) + self.jsonPage = QWidget() + self.jsonPage.setObjectName(u"jsonPage") + self.jsonPageLayout = QVBoxLayout(self.jsonPage) + self.jsonPageLayout.setObjectName(u"jsonPageLayout") + self.jsonPageLayout.setContentsMargins(0, 0, 0, 0) + self.jsonEditor = QPlainTextEdit(self.jsonPage) + self.jsonEditor.setObjectName(u"jsonEditor") + self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + self.jsonPageLayout.addWidget(self.jsonEditor) + + self.workspaceStack.addWidget(self.jsonPage) + self.emptyPage = QWidget() + self.emptyPage.setObjectName(u"emptyPage") + self.emptyPageLayout = QVBoxLayout(self.emptyPage) + self.emptyPageLayout.setObjectName(u"emptyPageLayout") + self.emptyWorkspaceLabel = QLabel(self.emptyPage) + self.emptyWorkspaceLabel.setObjectName(u"emptyWorkspaceLabel") + self.emptyWorkspaceLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.emptyPageLayout.addWidget(self.emptyWorkspaceLabel) + + self.workspaceStack.addWidget(self.emptyPage) + + self.workspaceEditorLayout.addWidget(self.workspaceStack) + self.workspaceSplitter.addWidget(self.workspace) self.workspaceLayout.addWidget(self.workspaceSplitter) @@ -164,6 +292,9 @@ class Ui_MainWindow(object): self.editToolbar = QToolBar(MainWindow) self.editToolbar.setObjectName(u"editToolbar") MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.editToolbar) + self.transformToolbar = QToolBar(MainWindow) + self.transformToolbar.setObjectName(u"transformToolbar") + MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.transformToolbar) self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuEdit.menuAction()) @@ -175,6 +306,7 @@ class Ui_MainWindow(object): self.menuFile.addAction(self.actionSave) self.menuFile.addAction(self.actionSaveAs) self.menuFile.addSeparator() + self.menuFile.addAction(self.actionClose) self.menuFile.addAction(self.actionExit) self.menuEdit.addAction(self.actionUndo) self.menuEdit.addAction(self.actionRedo) @@ -182,6 +314,8 @@ class Ui_MainWindow(object): self.menuEdit.addAction(self.actionCopy) self.menuEdit.addAction(self.actionCut) self.menuEdit.addAction(self.actionPaste) + self.menuEdit.addAction(self.actionDelete) + self.menuEdit.addAction(self.actionSelectAll) self.menuEdit.addSeparator() self.menuEdit.addAction(self.actionSettings) self.menuView.addAction(self.menuPanels.menuAction()) @@ -197,9 +331,13 @@ class Ui_MainWindow(object): self.editToolbar.addAction(self.actionCopy) self.editToolbar.addAction(self.actionCut) self.editToolbar.addAction(self.actionPaste) + self.transformToolbar.addAction(self.actionRotateClockwise) self.retranslateUi(MainWindow) + self.workspaceStack.setCurrentIndex(0) + + QMetaObject.connectSlotsByName(MainWindow) # setupUi @@ -211,6 +349,13 @@ class Ui_MainWindow(object): #endif // QT_CONFIG(statustip) #if QT_CONFIG(shortcut) self.actionNew.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None)) +#endif // QT_CONFIG(shortcut) + self.actionRotateClockwise.setText(QCoreApplication.translate("MainWindow", u"Rotate Clockwise", None)) +#if QT_CONFIG(tooltip) + self.actionRotateClockwise.setToolTip(QCoreApplication.translate("MainWindow", u"Rotate selected blocks clockwise by 90 degrees", None)) +#endif // QT_CONFIG(tooltip) +#if QT_CONFIG(shortcut) + self.actionRotateClockwise.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+R", None)) #endif // QT_CONFIG(shortcut) self.actionOpen.setText(QCoreApplication.translate("MainWindow", u"&Open\u2026", None)) #if QT_CONFIG(statustip) @@ -233,6 +378,10 @@ class Ui_MainWindow(object): self.actionExit.setText(QCoreApplication.translate("MainWindow", u"E&xit", None)) #if QT_CONFIG(shortcut) self.actionExit.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None)) +#endif // QT_CONFIG(shortcut) + self.actionClose.setText(QCoreApplication.translate("MainWindow", u"&Close Document", None)) +#if QT_CONFIG(shortcut) + self.actionClose.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+W", None)) #endif // QT_CONFIG(shortcut) self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"&Undo", None)) #if QT_CONFIG(shortcut) @@ -257,6 +406,10 @@ class Ui_MainWindow(object): self.actionSelectAll.setText(QCoreApplication.translate("MainWindow", u"Select &All", None)) #if QT_CONFIG(shortcut) self.actionSelectAll.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+A", None)) +#endif // QT_CONFIG(shortcut) + self.actionDelete.setText(QCoreApplication.translate("MainWindow", u"&Delete", None)) +#if QT_CONFIG(shortcut) + self.actionDelete.setShortcut(QCoreApplication.translate("MainWindow", u"Del", None)) #endif // QT_CONFIG(shortcut) self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"&About BEdit", None)) self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"&Settings\u2026", None)) @@ -265,6 +418,25 @@ class Ui_MainWindow(object): #endif // QT_CONFIG(statustip) self.actionAboutQt.setText(QCoreApplication.translate("MainWindow", u"About &Qt", None)) self.panel_libraries.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", None)) + self.panel_document.setWindowTitle(QCoreApplication.translate("MainWindow", u"Document", None)) + self.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None)) +#if QT_CONFIG(tooltip) + self.navigateUpButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the containing graph", None)) +#endif // QT_CONFIG(tooltip) + self.graphBreadcrumbLabel.setText(QCoreApplication.translate("MainWindow", u"Untitled", None)) + self.workspaceModeLabel.setText(QCoreApplication.translate("MainWindow", u"Graph", None)) + self.applyJsonButton.setText(QCoreApplication.translate("MainWindow", u"Apply JSON", None)) + self.pointerToolButton.setText(QCoreApplication.translate("MainWindow", u"Pointer", None)) + self.inputToolButton.setText(QCoreApplication.translate("MainWindow", u"Input", None)) +#if QT_CONFIG(tooltip) + self.inputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface input", None)) +#endif // QT_CONFIG(tooltip) + self.outputToolButton.setText(QCoreApplication.translate("MainWindow", u"Output", None)) +#if QT_CONFIG(tooltip) + self.outputToolButton.setToolTip(QCoreApplication.translate("MainWindow", u"Add an interface output", None)) +#endif // QT_CONFIG(tooltip) + self.jsonEditor.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Component JSON", None)) + self.emptyWorkspaceLabel.setText(QCoreApplication.translate("MainWindow", u"No document open", None)) self.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None)) self.menuEdit.setTitle(QCoreApplication.translate("MainWindow", u"&Edit", None)) self.menuView.setTitle(QCoreApplication.translate("MainWindow", u"&View", None)) @@ -273,5 +445,6 @@ class Ui_MainWindow(object): self.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"&Help", None)) self.fileToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"File", None)) self.editToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Edit", None)) + self.transformToolbar.setWindowTitle(QCoreApplication.translate("MainWindow", u"Transform", None)) # retranslateUi diff --git a/BEdit/src/bedit/ui_settings_dialog.py b/BEdit/src/bedit/ui_settings_dialog.py index 488ee94..05a3dbd 100644 --- a/BEdit/src/bedit/ui_settings_dialog.py +++ b/BEdit/src/bedit/ui_settings_dialog.py @@ -16,8 +16,10 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, QImage, QKeySequence, QLinearGradient, QPainter, QPalette, QPixmap, QRadialGradient, QTransform) from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, - QFormLayout, QGroupBox, QSizePolicy, QSpacerItem, - QSpinBox, QTabWidget, QVBoxLayout, QWidget) + QFormLayout, QGroupBox, QHBoxLayout, QLabel, + QListWidget, QListWidgetItem, QPushButton, QSizePolicy, + QSpacerItem, QSpinBox, QTabWidget, QVBoxLayout, + QWidget) class Ui_SettingsDialog(object): def setupUi(self, SettingsDialog): @@ -55,6 +57,46 @@ class Ui_SettingsDialog(object): self.generalLayout.addItem(self.generalSpacer) self.settingsTabs.addTab(self.generalTab, "") + self.librariesTab = QWidget() + self.librariesTab.setObjectName(u"librariesTab") + self.librariesTabLayout = QVBoxLayout(self.librariesTab) + self.librariesTabLayout.setObjectName(u"librariesTabLayout") + self.libraryPathsLabel = QLabel(self.librariesTab) + self.libraryPathsLabel.setObjectName(u"libraryPathsLabel") + self.libraryPathsLabel.setWordWrap(True) + + self.librariesTabLayout.addWidget(self.libraryPathsLabel) + + self.libraryPathsList = QListWidget(self.librariesTab) + self.libraryPathsList.setObjectName(u"libraryPathsList") + + self.librariesTabLayout.addWidget(self.libraryPathsList) + + self.libraryPathButtonsLayout = QHBoxLayout() + self.libraryPathButtonsLayout.setObjectName(u"libraryPathButtonsLayout") + self.addLibraryFileButton = QPushButton(self.librariesTab) + self.addLibraryFileButton.setObjectName(u"addLibraryFileButton") + + self.libraryPathButtonsLayout.addWidget(self.addLibraryFileButton) + + self.addLibraryFolderButton = QPushButton(self.librariesTab) + self.addLibraryFolderButton.setObjectName(u"addLibraryFolderButton") + + self.libraryPathButtonsLayout.addWidget(self.addLibraryFolderButton) + + self.removeLibraryPathButton = QPushButton(self.librariesTab) + self.removeLibraryPathButton.setObjectName(u"removeLibraryPathButton") + + self.libraryPathButtonsLayout.addWidget(self.removeLibraryPathButton) + + self.libraryButtonsSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.libraryPathButtonsLayout.addItem(self.libraryButtonsSpacer) + + + self.librariesTabLayout.addLayout(self.libraryPathButtonsLayout) + + self.settingsTabs.addTab(self.librariesTab, "") self.dialogLayout.addWidget(self.settingsTabs) @@ -81,5 +123,10 @@ class Ui_SettingsDialog(object): self.autosaveGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Automatic saving", None)) self.autosaveIntervalSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" minutes", None)) self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None)) + self.libraryPathsLabel.setText(QCoreApplication.translate("SettingsDialog", u"Load library JSON files from these files or folders at startup:", None)) + self.addLibraryFileButton.setText(QCoreApplication.translate("SettingsDialog", u"Add File\u2026", None)) + self.addLibraryFolderButton.setText(QCoreApplication.translate("SettingsDialog", u"Add Folder\u2026", None)) + self.removeLibraryPathButton.setText(QCoreApplication.translate("SettingsDialog", u"Remove", None)) + self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.librariesTab), QCoreApplication.translate("SettingsDialog", u"Libraries", None)) # retranslateUi diff --git a/BEdit/src/bedit/workspace/__init__.py b/BEdit/src/bedit/workspace/__init__.py new file mode 100644 index 0000000..72dae2f --- /dev/null +++ b/BEdit/src/bedit/workspace/__init__.py @@ -0,0 +1,4 @@ +from bedit.workspace.view import GraphWorkspaceView + +__all__ = ["GraphWorkspaceView"] + diff --git a/BEdit/src/bedit/workspace/view.py b/BEdit/src/bedit/workspace/view.py new file mode 100644 index 0000000..53b8d0b --- /dev/null +++ b/BEdit/src/bedit/workspace/view.py @@ -0,0 +1,547 @@ +import json + +from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal +from PySide6.QtGui import ( + QColor, + QDragEnterEvent, + QDropEvent, + QMouseEvent, + QPainter, + QPainterPath, + QPen, + QTransform, +) +from PySide6.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsItem, + QGraphicsObject, + QGraphicsPathItem, + QGraphicsScene, + QGraphicsSceneContextMenuEvent, + QGraphicsSceneMouseEvent, + QGraphicsView, + QApplication, + QMenu, + QStyleOptionGraphicsItem, + QWidget, +) + +from bedit.document.controller import DocumentController +from bedit.document.model import Component, Connection, Endpoint, Port +from bedit.library.tree_model import COMPONENT_MIME_TYPE + + +SELECTION_MIME_TYPE = "application/x-bedit-selection" + + +class ConnectionPortItem(QGraphicsEllipseItem): + def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None: + super().__init__(-6, -6, 12, 12, parent) + self.endpoint = endpoint + self.role = role + self.setBrush(QColor("#ffffff")) + self.setPen(QPen(QColor("#303030"), 1.5)) + self.setZValue(2) + self.setToolTip(label) + + +class ComponentGraphicsItem(QGraphicsObject): + WIDTH = 120.0 + HEIGHT = 72.0 + + def __init__(self, component: Component, controller: DocumentController) -> None: + super().__init__() + self.component_id = component.id + self.component = component + self.controller = controller + self.drag_start = QPointF() + self.setFlags( + QGraphicsItem.GraphicsItemFlag.ItemIsMovable + | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable + | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges + ) + self.input_ports = self._create_ports(component.inputs, "target", 0.0) + self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH) + self.setTransformOriginPoint(self.WIDTH / 2, self.HEIGHT / 2) + self.setRotation(component.rotation) + + def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]: + result = {} + spacing = self.HEIGHT / (len(ports) + 1) + for index, port in enumerate(ports, start=1): + endpoint = Endpoint(block=self.component_id, port=port.id) + item = ConnectionPortItem(endpoint, role, port.name, self) + item.setPos(x, spacing * index) + result[port.id] = item + return result + + def boundingRect(self) -> QRectF: # noqa: N802 + return QRectF(0, 0, self.WIDTH, self.HEIGHT) + + def paint( + self, + painter: QPainter, + option: QStyleOptionGraphicsItem, + widget: QWidget | None = None, + ) -> None: + del option, widget + icon = self.component.icon + fill = QColor("#dbeafe") if self.isSelected() else QColor(icon.fill) + painter.setBrush(fill) + painter.setPen(QPen(QColor(icon.border), 1.5)) + if icon.shape == "ellipse": + painter.drawEllipse(self.boundingRect()) + else: + painter.drawRoundedRect(self.boundingRect(), 5, 5) + painter.setPen(QColor("#202020")) + painter.drawText( + self.boundingRect(), + Qt.AlignmentFlag.AlignCenter, + icon.text or self.component.name, + ) + + def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + self.controller.activate_component(self.component_id) + event.accept() + + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 + if not self.isSelected(): + scene = self.scene() + if scene is not None: + scene.clearSelection() + self.setSelected(True) + menu = QMenu() + options_action = menu.addAction("Component Options…") + if menu.exec(event.screenPos()) is options_action: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.componentOptionsRequested.emit(self.component_id) + event.accept() + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + self.drag_start = self.pos() + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + super().mouseReleaseEvent(event) + self.controller.move_component(self.component_id, self.drag_start, self.pos()) + + def itemChange(self, change, value): # noqa: N802 + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.update_connections_for_block(self.component_id) + return super().itemChange(change, value) + + +class InterfaceTerminalItem(QGraphicsObject): + WIDTH = 110.0 + HEIGHT = 36.0 + + def __init__(self, port: Port, direction: str, controller: DocumentController) -> None: + super().__init__() + self.port = port + self.direction = direction + self.controller = controller + self.drag_start = QPointF() + role = "source" if direction == "input" else "target" + self.connection_port = ConnectionPortItem( + Endpoint(interface=port.id), role, port.name, self + ) + connection_x = self.WIDTH if direction == "input" else 0.0 + self.connection_port.setPos(connection_x, self.HEIGHT / 2) + self.setFlags( + QGraphicsItem.GraphicsItemFlag.ItemIsMovable + | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable + | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges + ) + self.setToolTip(f"Component {direction}: {port.name}") + + def boundingRect(self) -> QRectF: # noqa: N802 + return QRectF(0, 0, self.WIDTH, self.HEIGHT) + + def paint( + self, + painter: QPainter, + option: QStyleOptionGraphicsItem, + widget: QWidget | None = None, + ) -> None: + del option, widget + painter.setBrush(QColor("#e5e7eb")) + painter.setPen(QPen(QColor("#4b5563"), 1.5)) + painter.drawRoundedRect(self.boundingRect(), 4, 4) + painter.setPen(QColor("#202020")) + marker = "IN" if self.direction == "input" else "OUT" + painter.drawText( + self.boundingRect().adjusted(8, 0, -8, 0), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + f"{marker} {self.port.name}", + ) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + self.drag_start = self.pos() + super().mousePressEvent(event) + + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 + menu = QMenu() + options_action = menu.addAction(f"{self.direction.title()} Options…") + if menu.exec(event.screenPos()) is options_action: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.portOptionsRequested.emit(self.port.id, self.direction) + event.accept() + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + super().mouseReleaseEvent(event) + self.controller.move_interface_port(self.port.id, self.drag_start, self.pos()) + + def itemChange(self, change, value): # noqa: N802 + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.update_connections_for_interface(self.port.id) + return super().itemChange(change, value) + + +class ConnectionGraphicsItem(QGraphicsPathItem): + def __init__(self, connection_id: str, name: str = "") -> None: + super().__init__() + self.connection_id = connection_id + self.name = name + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + self._update_pen() + self.setZValue(-1) + self.setToolTip(name or "Connection") + + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802 + menu = QMenu() + options_action = menu.addAction("Connection Options…") + if menu.exec(event.screenPos()) is options_action: + scene = self.scene() + if isinstance(scene, GraphScene): + scene.connectionOptionsRequested.emit(self.connection_id) + event.accept() + + def itemChange(self, change, value): # noqa: N802 + result = super().itemChange(change, value) + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged: + self._update_pen() + return result + + def _update_pen(self) -> None: + self.setPen( + QPen( + QColor("#f59e0b") if self.isSelected() else QColor("#285f9e"), + 4.0 if self.isSelected() else 2.5, + ) + ) + + +class GraphScene(QGraphicsScene): + componentOptionsRequested = Signal(str) + portOptionsRequested = Signal(str, str) + connectionOptionsRequested = Signal(str) + + def __init__(self, controller: DocumentController, parent=None) -> None: + super().__init__(parent) + self.controller = controller + self.component_items: dict[str, ComponentGraphicsItem] = {} + self.input_items: dict[str, InterfaceTerminalItem] = {} + self.output_items: dict[str, InterfaceTerminalItem] = {} + self.connection_items: dict[str, ConnectionGraphicsItem] = {} + self.pending_source: ConnectionPortItem | None = None + self.setSceneRect(-2000, -2000, 4000, 4000) + + controller.documentReset.connect(self.rebuild) + controller.activeGraphChanged.connect(self.rebuild) + controller.componentMoved.connect(self.set_component_position) + controller.componentRotated.connect(self.set_component_rotation) + self.rebuild() + + def rebuild(self) -> None: + self.clear() + self.component_items.clear() + self.input_items.clear() + self.output_items.clear() + self.connection_items.clear() + self.pending_source = None + owner = self.controller.active_component + if owner is None or owner.implementation_kind != "graph": + return + for port in owner.inputs: + item = InterfaceTerminalItem(port, "input", self.controller) + self.addItem(item) + item.setPos(port.x, port.y) + self.input_items[port.id] = item + for port in owner.outputs: + item = InterfaceTerminalItem(port, "output", self.controller) + self.addItem(item) + item.setPos(port.x, port.y) + self.output_items[port.id] = item + for component in owner.graph.blocks.values(): + item = ComponentGraphicsItem(component, self.controller) + self.addItem(item) + item.setPos(component.x, component.y) + self.component_items[component.id] = item + for connection in owner.graph.connections.values(): + item = ConnectionGraphicsItem(connection.id, connection.name) + self.addItem(item) + self.connection_items[connection.id] = item + self.update_connection(connection.id) + + def set_component_position(self, component_id: str, position: QPointF) -> None: + item = self.component_items.get(component_id) + if item is not None and item.pos() != position: + item.setPos(position) + + def set_component_rotation(self, component_id: str, rotation: float) -> None: + item = self.component_items.get(component_id) + if item is not None: + item.setRotation(rotation) + self.update_connections_for_block(component_id) + + def update_connections_for_block(self, component_id: str) -> None: + for connection in self.controller.active_graph.connections.values(): + if component_id in (connection.source.block, connection.target.block): + self.update_connection(connection.id) + + def update_connections_for_interface(self, port_id: str) -> None: + for connection in self.controller.active_graph.connections.values(): + if port_id in (connection.source.interface, connection.target.interface): + self.update_connection(connection.id) + + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802 + painter.fillRect(rect, QColor("#e4e4e4")) + if self.controller.document is None or self.controller.active_component is None: + return + spacing = 32 + left = int(rect.left()) - (int(rect.left()) % spacing) + top = int(rect.top()) - (int(rect.top()) % spacing) + painter.setPen(QPen(QColor("#b8b8b8"), 1)) + for x in range(left, int(rect.right()) + spacing, spacing): + for y in range(top, int(rect.bottom()) + spacing, spacing): + painter.drawPoint(x, y) + + def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None: + if endpoint.interface is not None: + terminals = self.input_items if role == "source" else self.output_items + terminal = terminals.get(endpoint.interface) + return terminal.connection_port if terminal else None + component = self.component_items.get(endpoint.block or "") + if component is None: + return None + ports = component.output_ports if role == "source" else component.input_ports + return ports.get(endpoint.port or "") + + def update_connection(self, connection_id: str) -> None: + connection = self.controller.active_graph.connections.get(connection_id) + graphics = self.connection_items.get(connection_id) + if connection is None or graphics is None: + return + source = self._endpoint_item(connection.source, "source") + target = self._endpoint_item(connection.target, "target") + if source is None or target is None: + return + start, end = source.scenePos(), target.scenePos() + distance = max(50.0, abs(end.x() - start.x()) * 0.5) + path = QPainterPath(start) + path.cubicTo(start + QPointF(distance, 0), end - QPointF(distance, 0), end) + graphics.setPath(path) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 + item = self.itemAt(event.scenePos(), QTransform()) + if isinstance(item, ConnectionPortItem): + if item.role == "source": + self._clear_pending_source() + self.pending_source = item + item.setBrush(QColor("#f5b642")) + elif self.pending_source is not None: + if self.pending_source.endpoint != item.endpoint: + self.controller.connect(self.pending_source.endpoint, item.endpoint) + self._clear_pending_source() + event.accept() + return + self._clear_pending_source() + super().mousePressEvent(event) + + def _clear_pending_source(self) -> None: + if self.pending_source is not None: + self.pending_source.setBrush(QColor("#ffffff")) + self.pending_source = None + + +class GraphWorkspaceView(QGraphicsView): + toolUsed = Signal() + componentOptionsRequested = Signal(str) + portOptionsRequested = Signal(str, str) + connectionOptionsRequested = Signal(str) + selectionAvailabilityChanged = Signal(bool) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.controller: DocumentController | None = None + self.tool_mode = "pointer" + self.paste_count = 0 + self.setAcceptDrops(True) + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) + self.setBackgroundBrush(QColor("#9a9a9a")) + + def set_model(self, controller: DocumentController) -> None: + self.controller = controller + scene = GraphScene(controller, self) + scene.componentOptionsRequested.connect(self.componentOptionsRequested) + scene.portOptionsRequested.connect(self.portOptionsRequested) + scene.connectionOptionsRequested.connect(self.connectionOptionsRequested) + scene.selectionChanged.connect( + lambda: self.selectionAvailabilityChanged.emit(bool(scene.selectedItems())) + ) + self.setScene(scene) + + def select_all(self) -> None: + scene = self.scene() + if scene is None: + return + for item in scene.items(): + if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsSelectable: + item.setSelected(True) + + def delete_selected(self) -> None: + if self.controller is None or not isinstance(self.scene(), GraphScene): + return + blocks: set[str] = set() + connections: set[str] = set() + inputs: set[str] = set() + outputs: set[str] = set() + for item in self.scene().selectedItems(): + if isinstance(item, ComponentGraphicsItem): + blocks.add(item.component_id) + elif isinstance(item, ConnectionGraphicsItem): + connections.add(item.connection_id) + elif isinstance(item, InterfaceTerminalItem): + (inputs if item.direction == "input" else outputs).add(item.port.id) + self.controller.delete_selection(blocks, connections, inputs, outputs) + + def has_selected_components(self) -> bool: + scene = self.scene() + return bool( + scene + and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()) + ) + + def rotate_selected(self) -> None: + if self.controller is None or self.scene() is None: + return + component_ids = { + item.component_id + for item in self.scene().selectedItems() + if isinstance(item, ComponentGraphicsItem) + } + self.controller.rotate_components(component_ids) + + def copy_selection(self) -> bool: + if self.controller is None or not isinstance(self.scene(), GraphScene): + return False + selected_ids = { + item.component_id + for item in self.scene().selectedItems() + if isinstance(item, ComponentGraphicsItem) + } + if not selected_ids: + return False + graph = self.controller.active_graph + components = [graph.blocks[component_id].to_dict() for component_id in selected_ids] + connections = [ + connection.to_dict() + for connection in graph.connections.values() + if connection.source.block in selected_ids and connection.target.block in selected_ids + ] + mime_data = QMimeData() + mime_data.setData( + SELECTION_MIME_TYPE, + json.dumps({"components": components, "connections": connections}).encode("utf-8"), + ) + QApplication.clipboard().setMimeData(mime_data) + self.paste_count = 0 + return True + + def cut_selection(self) -> None: + if self.copy_selection(): + self.delete_selected() + + def paste_selection(self) -> None: + if self.controller is None: + return + mime_data = QApplication.clipboard().mimeData() + if not mime_data.hasFormat(SELECTION_MIME_TYPE): + return + try: + payload = json.loads(bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8")) + components = [Component.from_dict(item) for item in payload.get("components", [])] + connections = [Connection.from_dict(item) for item in payload.get("connections", [])] + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return + self.paste_count += 1 + new_ids = self.controller.paste_selection( + components, + connections, + QPointF(32 * self.paste_count, 32 * self.paste_count), + ) + scene = self.scene() + if isinstance(scene, GraphScene): + scene.clearSelection() + for component_id in new_ids: + item = scene.component_items.get(component_id) + if item is not None: + item.setSelected(True) + + def set_tool_mode(self, mode: str) -> None: + self.tool_mode = mode + self.setDragMode( + QGraphicsView.DragMode.RubberBandDrag + if mode == "pointer" + else QGraphicsView.DragMode.NoDrag + ) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if ( + self.controller is not None + and self.tool_mode in {"input", "output"} + and event.button() == Qt.MouseButton.LeftButton + ): + self.controller.add_interface_port(self.tool_mode, self.mapToScene(event.position().toPoint())) + self.toolUsed.emit() + event.accept() + return + super().mousePressEvent(event) + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 + if ( + event.mimeData().hasFormat(COMPONENT_MIME_TYPE) + and self.controller is not None + and self.controller.active_component is not None + and self.controller.active_component.implementation_kind == "graph" + ): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event) -> None: # noqa: N802 + if ( + event.mimeData().hasFormat(COMPONENT_MIME_TYPE) + and self.controller is not None + and self.controller.active_component is not None + and self.controller.active_component.implementation_kind == "graph" + ): + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 + if self.controller is None or not event.mimeData().hasFormat(COMPONENT_MIME_TYPE): + super().dropEvent(event) + return + data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8")) + source = Component.from_dict(data) + self.controller.add_component_copy(source, self.mapToScene(event.position().toPoint())) + event.acceptProposedAction() diff --git a/BEdit/ui/component_options_dialog.ui b/BEdit/ui/component_options_dialog.ui new file mode 100644 index 0000000..e43d21b --- /dev/null +++ b/BEdit/ui/component_options_dialog.ui @@ -0,0 +1,32 @@ + + + ComponentOptionsDialog + + 00420260 + Component Options + + + + Name: + + Icon shape: + rectangleellipse + Icon text: + + Fill color: + #dbeafe + Border color: + #303030 + Show contained components in the Libraries treetrue + + + Qt::Orientation::Vertical2040 + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + buttonBoxaccepted()ComponentOptionsDialogaccept() + buttonBoxrejected()ComponentOptionsDialogreject() + + diff --git a/BEdit/ui/main_window.ui b/BEdit/ui/main_window.ui index 83d38c7..36b48e3 100644 --- a/BEdit/ui/main_window.ui +++ b/BEdit/ui/main_window.ui @@ -94,19 +94,91 @@ + + + 22091 + + Document + 1 + + + 0 + 0 + 0 + 0 + 0 + + + true + true + + + + + - + 1 0 - - QWidget#workspace { - background-color: rgb(198, 198, 198); -} - + + 0 + 0 + 0 + 0 + 0 + + + 034 + 1677721534 + QFrame::Shape::StyledPanel + + 6 + 2 + 6 + 2 + UpOpen the containing graph + Untitled + Graph + Qt::Orientation::Horizontal4020 + Apply JSONfalse + Pointertruetruetrue + InputAdd an interface inputtruetrue + OutputAdd an interface outputtruetrue + + + + + + 0 + + + 0000 + + + + + + 0000 + QPlainTextEdit::LineWrapMode::NoWrapComponent JSON + + + + + + + No document open + Qt::AlignmentFlag::AlignCenter + + + + + + + @@ -131,6 +203,7 @@ + @@ -143,6 +216,8 @@ + + @@ -230,6 +305,12 @@ + + Transform + TopToolBarArea + false + + @@ -245,6 +326,15 @@ Ctrl+N + + + + :/icons/icons/transform-rotate.png:/icons/icons/transform-rotate.png + + Rotate Clockwise + Rotate selected blocks clockwise by 90 degrees + Ctrl+R + @@ -295,6 +385,10 @@ Ctrl+Q + + &Close Document + Ctrl+W + @@ -363,6 +457,10 @@ Ctrl+A + + &Delete + Del + &About BEdit @@ -382,6 +480,13 @@ + + + GraphWorkspaceView + QGraphicsView +
bedit.workspace.view
+
+
diff --git a/BEdit/ui/settings_dialog.ui b/BEdit/ui/settings_dialog.ui index 2329a64..9491022 100644 --- a/BEdit/ui/settings_dialog.ui +++ b/BEdit/ui/settings_dialog.ui @@ -73,6 +73,51 @@ + + + Libraries + + + + + + Load library JSON files from these files or folders at startup: + + + true + + + + + + + + + + + Add File… + + + + + Add Folder… + + + + + Remove + + + + + Qt::Orientation::Horizontal + 4020 + + + + + +