Compare commits

...

12 Commits

Author SHA1 Message Date
16d9cc7651 More fixes and extended lib 2026-08-18 15:44:24 +02:00
1d535c1f5b Small fixes and bigger library 2026-08-18 14:22:01 +02:00
60a30d4ee3 Start with signal lib 2026-08-17 18:26:41 +02:00
e88c58f095 Library drag-in 2026-08-17 17:52:12 +02:00
b1f453a6df Library tree filled with items 2026-08-17 17:47:26 +02:00
8d37b2441a lib settings 2026-08-17 17:44:58 +02:00
a61c8e6003 Labels 2026-08-17 13:03:29 +02:00
4591e6b7b0 Fixed some stuff 2026-08-17 12:55:42 +02:00
cb9e03a6bf Connection points 2026-08-17 12:16:59 +02:00
0bb2141673 Copy/paste on canvas 2026-08-17 12:12:57 +02:00
fcee2ac8b6 Moving items in the graph 2026-08-17 11:49:13 +02:00
8e7d2d3efd Graph view shows graph 2026-08-17 11:29:07 +02:00
63 changed files with 5228 additions and 479 deletions

View File

@@ -60,7 +60,7 @@ Responsibilities:
- `views/`: handwritten widget/window/graphics behavior.
- `views/models/`: Qt item models used by views.
- `services/`: non-visual functionality such as files, clipboard, logging, and settings.
- `models.py`: GUI metadata persisted inside the core document, currently including icons and shapes.
- `models.py`: GUI metadata persisted inside the core document, including icon, graph, and simulation databases.
- `bedit_core`: domain model and serialization. It must never import from `bedit_gui`.
Preferred direction:
@@ -136,10 +136,12 @@ Clipboard support is intentionally extensible:
- `services/component_clipboard.py`: component payload serialization and ID remapping.
- `controllers/clipboard_controller.py`: focus-based action router and handlers.
`ClipboardHandler` is the base implementation for future editors. Add a graph-editor handler later by subclassing it and registering that handler in `application.py`.
`ClipboardHandler` is the base implementation for editor-specific routing. The document tree and graph editor have component handlers registered in `application.py`.
Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Component clipboard data uses the custom BEdit MIME type and JSON; never use pickle or live object references.
The graph handler copies/cuts/deletes selected component items and deletes selected connection items. Paste targets the displayed graph and places new components at the mouse position, or at the viewport center when the mouse is outside the canvas.
## Icon editor conventions
- Icons are GUI metadata stored in `document.metadata["icon_database"]`.
@@ -153,6 +155,23 @@ Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Componen
- Keep reusable widgets such as the RGBA color button independent of the icon editor.
- Static icon previews belong in rendering utilities, not in the interactive editor scene.
## Graph editor conventions
- Graph GUI metadata is stored in `document.metadata["graph_database"]`. `GraphDatabase`, `Graph`, `GraphConnection`, and `GraphComponentLabel` live in `bedit_gui.models` and serialize through `to_data()`/`from_data()`.
- Core graph topology and connections remain in `bedit_core.models`; positions, routed points, labels, and other presentation metadata belong in the GUI graph database.
- Component positions are absolute scene positions. Connection metadata stores the full point list, including endpoints; only interior points are draggable corner items.
- Component labels are visible by default, italic, and centered below the rendered icon. Their persisted position is relative to the icons bottom-center. Label visibility and completed label moves are undoable.
- The graph editor has normal and connection modes. The toolbar actions are exclusive and Space toggles modes while focus is inside the editor.
- Normal mode supports component and label dragging. Connection mode shows icon ports, prevents component/label movement, and uses two component clicks to choose a compatible port pair.
- Signal connections require output-to-input. Signal outputs may fan out; signal inputs accept only one connection. A bond port accepts another connection only when its `multiplicity` is true. Bond domains must be compatible.
- While choosing a connection, the first component is highlighted and a temporary dashed line follows the mouse. When several port pairs are possible, output-to-input choices are listed first.
- Signal connections render with full arrows. Bond connections render with half arrows and a perpendicular causality tick. Connection endpoints are clipped to rendered icon bounds plus `CONNECTION_BOUNDING_BOX_SPACING`.
- Components, connections, and connection points are separate graphics items. Connections are selectable/deletable; connection points are draggable and have their own delete context action.
- Persistent canvas edits go through `Document` methods and graph-specific `QUndoCommand` classes. Incremental document signals must also update the editors cached `Graph`; otherwise rebuilding items during a mode switch can restore stale metadata.
- `render_icon(..., render_ports=True)` is the single port-rendering path. Do not duplicate icon or port geometry in the graph editor.
- Graph sizing and rendering constants, including `COMPONENT_LABEL_FONT_SIZE`, live near the top of `views/graph_editor_widget.py`.
- Double-clicking a canvas component selects its document-tree row and opens its graph or equation editor. Canvas component context menus share the document-tree editing actions and add graph-only presentation actions such as Show Label.
## Actions and shortcut routing
- Put visible actions in Designer menus/toolbars.
@@ -161,6 +180,12 @@ Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Componen
- Scope destructive shortcuts to the relevant widget where appropriate.
- Always guard the operation itself even when an action is disabled for presentation.
## Application settings
- `ApplicationSettings` is the typed `QSettings` facade for BEdit preferences. The current settings include log level, graph snap-to-grid size, and ordered library paths under `libraries/paths`.
- The Settings dialog edits library paths locally until OK is accepted. It supports BEdit `.bedit.json`/`.json` and `.beb` files, directories, duplicate suppression, extended selection, and list-focused Delete-key removal.
- Add settings behavior in the handwritten dialog/controller/service modules, never in generated UI Python.
## Simulator application
- `bedit_gui/simulation_application.py` is the composition root for the separate `bedit-sim` Qt application.
@@ -173,6 +198,14 @@ Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Componen
- BEdit launches the simulator as a separate process. Compile/Open Simulation integration may transfer a `.bes` file to that process.
- Keep simulator file workflows in their own services/controllers rather than adding them to `MainWindow` or the editor document controller.
- Keep compiled-executable launching, time-window progression, result loading, and cancellation inside `bedit_simulation`. GUI controllers may schedule backend calls and present state, but must not execute or manage simulation binaries themselves.
- The BEdit Compile action performs bond-graph causality inference before Modelica compilation. Inference runs on a deep copy first, then inferred `causality`/`undesired` state is applied to the open document with an undoable command.
- OpenModelica compilation runs `checkModel(...)` before `buildModel(...)`; the equation/variable summary flows through `ModelBuildResult.output` and is shown in the application log.
### Causality inference caveats
- `_CausalityEngine.inference()` clears every flattened bonds causality to `NONE` before inference; it does not continue from saved causalities. It currently does not clear old `undesired` flags.
- Preferred causalities are assigned before all junction constraints are resolved, and the engine has no backtracking to relax a preferred assignment. Some soft-preference conflicts therefore raise a junction error instead of marking a bond undesired.
- `propagate_to_neighbor()` currently selects `connection.target` in both branches. When propagation starts at a target component, it should traverse to the source; account for this known bug when diagnosing inference behavior.
## Verification

BIN
examples/BondGraphs.beb Normal file

Binary file not shown.

25
icons/signal_base.json Normal file
View File

@@ -0,0 +1,25 @@
{
"shapes": {
"14409738-45af-40c5-9c9e-827218d54a95": {
"layer": 0,
"type": "rectangle",
"pos": [
-48,
-48
],
"width": 96.0,
"height": 96.0,
"line_type": "solid",
"line_thickness": 5.0,
"corner_radius": 8.0,
"line_color": "#00007fff",
"fill_color": "#ebebebff"
}
},
"port_positions": {
"d5681d1d-70ee-4623-a41a-5a572e46d42c": [
-8,
-8
]
}
}

View File

@@ -0,0 +1,79 @@
{
"shapes": {
"9ed4189a-123b-431f-a6bc-bbf169487703": {
"layer": 0,
"type": "rectangle",
"pos": [-48, -48],
"width": 96.0,
"height": 96.0,
"line_type": "solid",
"line_thickness": 5.0,
"corner_radius": 8.0,
"line_color": "#00007fff",
"fill_color": "#ebebebff"
},
"ac1b7cf2-ce50-44b7-9f8f-077ca36f7369": {
"layer": 4,
"type": "line",
"pos": [-32, -32],
"end": [-32, 32],
"line_type": "solid",
"line_thickness": 3.0,
"line_color": "#000000ff"
},
"2e623060-349e-4a48-97b0-a81921df5870": {
"layer": 2,
"type": "line",
"pos": [-32, 32],
"end": [32, 32],
"line_type": "solid",
"line_thickness": 3.0,
"line_color": "#000000ff"
},
"2c787197-3228-4f20-a57d-6fca410151b9": {
"layer": 5, "type": "line", "pos": [-28, -16], "end": [-24, -15], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"53e49b2b-f3d6-4aa5-bd56-7ca7fbf68bb4": {
"layer": 5, "type": "line", "pos": [-24, -15], "end": [-20, -11], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"7df5a7ce-b914-459e-93f6-f43450623baf": {
"layer": 5, "type": "line", "pos": [-20, -11], "end": [-16, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"dd89bdfb-f10f-467a-aa90-a534e1f30dd8": {
"layer": 5, "type": "line", "pos": [-16, -7], "end": [-12, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"df9439f1-588f-46d8-873e-ecc59f5b8e36": {
"layer": 5, "type": "line", "pos": [-12, 0], "end": [-8, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"4984ed23-a303-494f-947d-a34964220298": {
"layer": 5, "type": "line", "pos": [-8, 7], "end": [-4, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"af40c4f7-d33b-426e-b130-ce78184a350f": {
"layer": 5, "type": "line", "pos": [-4, 13], "end": [0, 16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"63c0188e-4f24-4ec7-b8d6-6258ab13c226": {
"layer": 5, "type": "line", "pos": [0, 16], "end": [4, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"c71d5e3b-a917-4dc8-8411-2046a54e2134": {
"layer": 5, "type": "line", "pos": [4, 13], "end": [8, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"51e128e6-c63f-4437-b56f-d20c31d195e2": {
"layer": 5, "type": "line", "pos": [8, 7], "end": [12, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"f135f861-3e98-4bb1-ab43-e51fa0eec8db": {
"layer": 5, "type": "line", "pos": [12, 0], "end": [16, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"422ce3eb-8d0f-4b05-bb54-126583521240": {
"layer": 5, "type": "line", "pos": [16, -7], "end": [20, -11], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"c2c60fc4-fd4c-4cfc-a90a-dd729fe551b0": {
"layer": 5, "type": "line", "pos": [20, -11], "end": [24, -15], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"0852263b-237a-4a32-a56d-91945864e01b": {
"layer": 5, "type": "line", "pos": [24, -15], "end": [28, -16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
}
},
"port_positions": {
"37eb6e52-6e89-4e09-8923-43c6230f5486": [-8, -8]
}
}

View File

@@ -0,0 +1,97 @@
{
"shapes": {
"9ed4189a-123b-431f-a6bc-bbf169487703": {
"layer": 0,
"type": "rectangle",
"pos": [
-48,
-48
],
"width": 96.0,
"height": 96.0,
"line_type": "solid",
"line_thickness": 5.0,
"corner_radius": 8.0,
"line_color": "#00007fff",
"fill_color": "#ebebebff"
},
"ac1b7cf2-ce50-44b7-9f8f-077ca36f7369": {
"layer": 4,
"type": "line",
"pos": [
-32,
-32
],
"end": [
-32,
32
],
"line_type": "solid",
"line_thickness": 3.0,
"line_color": "#000000ff"
},
"2e623060-349e-4a48-97b0-a81921df5870": {
"layer": 2,
"type": "line",
"pos": [
-32,
32
],
"end": [
32,
32
],
"line_type": "solid",
"line_thickness": 3.0,
"line_color": "#000000ff"
},
"5cfcb42c-5cc3-48aa-ad92-7d19807c95a0": {
"layer": 5, "type": "line", "pos": [-28, 0], "end": [-24, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"78f194ed-dae0-470a-a354-f7c4c3e54c81": {
"layer": 5, "type": "line", "pos": [-24, -7], "end": [-20, -13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"cfa29bc9-5fac-4f00-bb02-52498fd14f92": {
"layer": 5, "type": "line", "pos": [-20, -13], "end": [-16, -16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"8f852157-0395-4028-a9ce-f7bd596f2976": {
"layer": 5, "type": "line", "pos": [-16, -16], "end": [-12, -16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"6f535277-a28e-4abc-82d4-4cf515a80357": {
"layer": 5, "type": "line", "pos": [-12, -16], "end": [-8, -13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"a40bba3a-d9fd-4a7c-b409-2222ce69c558": {
"layer": 5, "type": "line", "pos": [-8, -13], "end": [-4, -7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"2720ddd9-0332-46b6-bb79-dd90aa0892bd": {
"layer": 5, "type": "line", "pos": [-4, -7], "end": [0, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"e420ce57-f79d-4941-8f5a-8207e15f0306": {
"layer": 5, "type": "line", "pos": [0, 0], "end": [4, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"f53f0310-7e14-4a87-aeb3-e079e2a8460e": {
"layer": 5, "type": "line", "pos": [4, 7], "end": [8, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"915cce34-b991-4119-88b5-c2dd41eec73c": {
"layer": 5, "type": "line", "pos": [8, 13], "end": [12, 16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"56d62c0f-4156-49eb-b048-b3b42304d6b0": {
"layer": 5, "type": "line", "pos": [12, 16], "end": [16, 16], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"de990624-dbb2-434c-bbdf-b0ea9caa34bd": {
"layer": 5, "type": "line", "pos": [16, 16], "end": [20, 13], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"b48fb633-b679-433f-8bee-f7c9549235aa": {
"layer": 5, "type": "line", "pos": [20, 13], "end": [24, 7], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
},
"17f88457-c04d-4c08-90f6-f6ec5166c11c": {
"layer": 5, "type": "line", "pos": [24, 7], "end": [28, 0], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff"
}
},
"port_positions": {
"37eb6e52-6e89-4e09-8923-43c6230f5486": [
-8,
-8
]
}
}

View File

@@ -0,0 +1,55 @@
{
"shapes": {
"9ed4189a-123b-431f-a6bc-bbf169487703": {
"layer": 0,
"type": "rectangle",
"pos": [
-48,
-48
],
"width": 96.0,
"height": 96.0,
"line_type": "solid",
"line_thickness": 5.0,
"corner_radius": 8.0,
"line_color": "#00007fff",
"fill_color": "#ebebebff"
},
"ac1b7cf2-ce50-44b7-9f8f-077ca36f7369": {
"layer": 4,
"type": "line",
"pos": [
-32,
-32
],
"end": [
-32,
32
],
"line_type": "solid",
"line_thickness": 3.0,
"line_color": "#000000ff"
},
"2e623060-349e-4a48-97b0-a81921df5870": {
"layer": 2,
"type": "line",
"pos": [
-32,
32
],
"end": [
32,
32
],
"line_type": "solid",
"line_thickness": 3.0,
"line_color": "#000000ff"
}
},
"port_positions": {
"37eb6e52-6e89-4e09-8923-43c6230f5486": [
-8,
-8
]
}
}

Binary file not shown.

View File

@@ -1,25 +0,0 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "0"
}
},
"port_positions": {
"497b1f74-1186-471f-976a-36b07a451caf": [
-8,
-8
]
}
}

View File

@@ -1,25 +0,0 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "1"
}
},
"port_positions": {
"4306701a-6b1b-4d19-b8ff-45dbfa04f2d3": [
-8,
-8
]
}
}

View File

@@ -1,29 +0,0 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64.0,
"height": 64.0,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "C"
}
},
"port_positions": {
"c87881f1-23b4-4a69-b586-8e3c7bf6e21e": [
-8,
-8
],
"c62de8eb-e13b-4849-bea5-c8cc5332269e": [
16,
-32
]
}
}

View File

@@ -1,29 +0,0 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "I"
}
},
"port_positions": {
"0b9036b1-e4e4-437e-8c35-f5bb391b6cbd": [
-8,
-8
],
"fb25f35d-4c0c-4cfa-92d4-1a18a71e2c11": [
16,
-32
]
}
}

View File

@@ -1,29 +0,0 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "R"
}
},
"port_positions": {
"0ac7d4ea-77f7-4c0d-8e37-406ef66e4740": [
-8,
-8
],
"d1f5aab2-7bff-4b1e-8697-98fb6c3d8f02": [
16,
-32
]
}
}

View File

@@ -1,25 +0,0 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-48,
-32
],
"width": 96.0,
"height": 64.0,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "Se"
}
},
"port_positions": {
"5b6a8a0c-0875-402c-bd74-11d086aac372": [
-8,
-8
]
}
}

View File

@@ -1,25 +0,0 @@
{
"shapes": {
"f1a38ee1-8a53-454d-a398-12c14032ba79": {
"layer": 0,
"type": "text",
"pos": [
-48,
-32
],
"width": 96.0,
"height": 64.0,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "Sf"
}
},
"port_positions": {
"856b4152-e18f-46d7-889d-626cb98474aa": [
-8,
-8
]
}
}

BIN
lib/signal.beb Normal file

Binary file not shown.

BIN
lib/signal_sources.beb Normal file

Binary file not shown.

View File

@@ -83,6 +83,7 @@ class _CausalityEngine():
def clear_causalities(self) -> None:
for bond in self._network.bonds:
bond.connection.causality = BondCausality.NONE
bond.connection.undesired = False
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
attached_connections = self._network.bonds_for(port)
@@ -106,7 +107,7 @@ class _CausalityEngine():
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
# Get neighor
is_source = (component == connection.source.component)
neighbor = connection.target if is_source else connection.target
neighbor = connection.target if is_source else connection.source
# Direct the evaluation based on what type of port it is
if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
self.evaluate_junction_constraints(neighbor)

View File

@@ -5,9 +5,10 @@ import argparse
from PySide6.QtWidgets import QApplication, QMessageBox
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, GraphEditorClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.library_controller import LibraryController
from bedit_gui.controllers.settings_controller import SettingsController
from bedit_gui.controllers.simulation_settings_controller import SimulationSettingsController
from bedit_gui.controllers.simulation_controller import SimulationController
@@ -46,20 +47,22 @@ def main() -> int:
settings = ApplicationSettings()
document = Document(app)
window = MainWindow()
window = MainWindow(settings.snap_to_grid_size)
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}"))
window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
LogController(window, settings.log_level)
DocumentController(document, window)
SettingsController(window, settings)
settings_controller = SettingsController(window, settings)
SimulationSettingsController(document, window)
SimulationController(document, window)
UndoController(document, window)
ViewMenuController(window)
document_tree_controller = DocumentTreeController(document, window)
library_controller = LibraryController(window, settings)
settings_controller.library_paths_changed.connect(library_controller.reload)
clipboard = ClipboardService(app)
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard)])
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard), GraphEditorClipboardHandler(document, window.graph_editor, clipboard)])
window_state_controller = WindowStateController(app, window)
window_state_controller.restore()

View File

@@ -0,0 +1,44 @@
from PySide6.QtGui import QUndoCommand
from bedit_core.models import BondCausality, BondConnection, Component, ConnectionID, GraphImplementation
CausalityState = dict[ConnectionID, tuple[BondCausality, bool]]
def causality_state(component: Component) -> CausalityState:
state = {}
if not isinstance(component.implementation, GraphImplementation):
return state
for connection_id, connection in component.implementation.graph.connections.items():
if isinstance(connection, BondConnection):
state[connection_id] = (connection.causality, connection.undesired)
for child in component.implementation.graph.components.values():
state.update(causality_state(child))
return state
class ChangeCausalityCommand(QUndoCommand):
def __init__(self, document: object, component: Component, inferred_component: Component) -> None:
super().__init__("Infer causality")
self.document = document
self.component = component
self.old_state = causality_state(component)
self.new_state = causality_state(inferred_component)
def redo(self) -> None:
self._apply(self.component, self.new_state)
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self._apply(self.component, self.old_state)
self.document.model_changed.emit(self.document.model)
@classmethod
def _apply(cls, component: Component, state: CausalityState) -> None:
if not isinstance(component.implementation, GraphImplementation):
return
for connection_id, connection in component.implementation.graph.connections.items():
if isinstance(connection, BondConnection) and connection_id in state:
connection.causality, connection.undesired = state[connection_id]
for child in component.implementation.graph.components.values():
cls._apply(child, state)

View File

@@ -1,44 +1,52 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface
from bedit_gui.models import Icon
from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface, PortID
from bedit_gui.models import Icon, PortMetadata
class AddEmptyGraphComponent(QUndoCommand):
def __init__(self, document: object, parent: Component) -> None:
def __init__(self, document: object, parent: Component | dict[ComponentID, Component]) -> None:
super().__init__("Add graph component")
if not isinstance(parent.implementation, GraphImplementation):
raise TypeError("parent component must have a graph implementation")
self.document = document
self.graph = parent.implementation.graph
if isinstance(parent, Component):
if not isinstance(parent.implementation, GraphImplementation):
raise TypeError("parent component must have a graph implementation")
self.components = parent.implementation.graph.components
else:
self.components = parent
self.component_id = ComponentID()
self.component = Component(name="New Graph Component", interface=Interface(), parameters={}, implementation=GraphImplementation(Graph()))
self.component = Component(name="new_graph_component", interface=Interface(), parameters={}, implementation=GraphImplementation(Graph()))
def redo(self) -> None:
self.graph.components[self.component_id] = self.component
self.components[self.component_id] = self.component
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
del self.graph.components[self.component_id]
del self.components[self.component_id]
self.document.model_changed.emit(self.document.model)
class AddEmptyEquationComponent(QUndoCommand):
def __init__(self, document: object, parent: Component) -> None:
def __init__(self, document: object, parent: Component | dict[ComponentID, Component]) -> None:
super().__init__("Add equation component")
if not isinstance(parent.implementation, GraphImplementation):
raise TypeError("parent component must have a graph implementation")
self.document = document
self.graph = parent.implementation.graph
if isinstance(parent, Component):
if not isinstance(parent.implementation, GraphImplementation):
raise TypeError("parent component must have a graph implementation")
self.components = parent.implementation.graph.components
else:
self.components = parent
self.component_id = ComponentID()
self.component = Component(name="New Equation Component", interface=Interface(), parameters={}, implementation=EquationImplementation())
def redo(self) -> None:
self.graph.components[self.component_id] = self.component
self.components[self.component_id] = self.component
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
del self.graph.components[self.component_id]
del self.components[self.component_id]
self.document.model_changed.emit(self.document.model)
@@ -56,10 +64,16 @@ class DeleteComponent(QUndoCommand):
if connection.source in port_ids or connection.target in port_ids:
self.connections.append((index, connection_id, connection))
self.icons = {}
self.port_metadata = {}
for component_id in self._component_ids(self.component_id, component):
icon = document.stored_component_icon(component_id)
if icon is not None:
self.icons[component_id] = icon
database = document._port_metadata_database(False)
if database is not None:
for port_id in self._port_ids(component):
if port_id in database.ports:
self.port_metadata[port_id] = deepcopy(database.ports[port_id])
def redo(self) -> None:
if self.parent_graph is not None:
@@ -69,6 +83,11 @@ class DeleteComponent(QUndoCommand):
self.document.model_changed.emit(self.document.model)
for component_id in self.icons:
self.document._set_component_icon(component_id, None)
if self.port_metadata:
database = self.document.port_metadata_database()
for port_id in self.port_metadata:
database.ports.pop(port_id, None)
self.document._set_port_metadata_database(database)
def undo(self) -> None:
self._restore_item(self.components, self.component_id, self.component, self.component_index)
@@ -78,6 +97,10 @@ class DeleteComponent(QUndoCommand):
self.document.model_changed.emit(self.document.model)
for component_id, icon in self.icons.items():
self.document._set_component_icon(component_id, icon)
if self.port_metadata:
database = self.document.port_metadata_database()
database.ports.update(deepcopy(self.port_metadata))
self.document._set_port_metadata_database(database)
@classmethod
def _find_component(cls, components: dict[ComponentID, Component], target: Component, parent_graph: Graph | None = None) -> tuple[dict[ComponentID, Component], ComponentID, Graph | None]:
@@ -99,6 +122,14 @@ class DeleteComponent(QUndoCommand):
component_ids.extend(cls._component_ids(child_id, child))
return component_ids
@classmethod
def _port_ids(cls, component: Component) -> list[PortID]:
port_ids = list(component.interface.ports)
if isinstance(component.implementation, GraphImplementation):
for child in component.implementation.graph.components.values():
port_ids.extend(cls._port_ids(child))
return port_ids
@staticmethod
def _restore_item(items: dict, item_id: object, item: object, index: int) -> None:
values = list(items.items())
@@ -108,22 +139,40 @@ class DeleteComponent(QUndoCommand):
class PasteComponents(QUndoCommand):
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
super().__init__("Paste components")
self.document = document
self.target = target
self.components = components
self.icons = icons
self.graph_id = graph_id
self.positions = positions or {}
self.port_metadata = deepcopy(port_metadata or {})
def redo(self) -> None:
self.target.update(self.components)
if self.graph_id is not None:
for component_id, position in self.positions.items():
self.document._set_graph_component_position(self.graph_id, component_id, position)
self.document.model_changed.emit(self.document.model)
for component_id, icon in self.icons.items():
self.document._set_component_icon(component_id, icon)
if self.port_metadata:
database = self.document.port_metadata_database()
database.ports.update(deepcopy(self.port_metadata))
self.document._set_port_metadata_database(database)
def undo(self) -> None:
for component_id in self.components:
self.target.pop(component_id, None)
if self.graph_id is not None:
for component_id in self.positions:
self.document._set_graph_component_position(self.graph_id, component_id, None)
self.document.model_changed.emit(self.document.model)
for component_id in self.icons:
self.document._set_component_icon(component_id, None)
if self.port_metadata:
database = self.document.port_metadata_database()
for port_id in self.port_metadata:
database.ports.pop(port_id, None)
self.document._set_port_metadata_database(database)

View File

@@ -0,0 +1,54 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, Connection, ConnectionID, GraphImplementation
class AddGraphConnectionCommand(QUndoCommand):
def __init__(self, document: object, graph_component: Component, connection: Connection) -> None:
super().__init__("Add connection")
if not isinstance(graph_component.implementation, GraphImplementation):
raise TypeError("component must have a graph implementation")
self.document = document
self.graph = graph_component.implementation.graph
self.connection_id = ConnectionID()
self.connection = connection
def redo(self) -> None:
self.graph.connections[self.connection_id] = self.connection
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.graph.connections.pop(self.connection_id, None)
self.document.model_changed.emit(self.document.model)
class DeleteGraphConnectionCommand(QUndoCommand):
def __init__(self, document: object, graph_component: Component, connection_id: ConnectionID) -> None:
super().__init__("Delete connection")
if not isinstance(graph_component.implementation, GraphImplementation):
raise TypeError("component must have a graph implementation")
self.document = document
self.graph_id = document.component_id(graph_component)
self.connections = graph_component.implementation.graph.connections
self.connection_id = connection_id
self.connection = self.connections[connection_id]
self.index = list(self.connections).index(connection_id)
database = document._graph_database(False)
graph = database.graphs.get(self.graph_id) if database is not None else None
self.visual_connection = deepcopy(graph.connections.get(connection_id)) if graph is not None else None
def redo(self) -> None:
self.connections.pop(self.connection_id, None)
self.document._set_graph_connection_points(self.graph_id, self.connection_id, None)
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
items = list(self.connections.items())
items.insert(self.index, (self.connection_id, self.connection))
self.connections.clear()
self.connections.update(items)
if self.visual_connection is not None:
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.visual_connection.points)
self.document.model_changed.emit(self.document.model)

View File

@@ -0,0 +1,24 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import ComponentID, ConnectionID
class ChangeGraphConnectionPointsCommand(QUndoCommand):
def __init__(self, document: object, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
super().__init__(text)
self.document = document
self.graph_id = graph_id
self.connection_id = connection_id
database = document._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
connection = graph.connections.get(connection_id) if graph is not None else None
self.old_points = deepcopy(connection.points) if connection is not None else None
self.new_points = deepcopy(points)
def redo(self) -> None:
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.new_points)
def undo(self) -> None:
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.old_points)

View File

@@ -0,0 +1,24 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import ComponentID
from bedit_gui.models import GraphComponentLabel
class ChangeGraphComponentLabelCommand(QUndoCommand):
def __init__(self, document: object, graph_id: ComponentID, component_id: ComponentID, label: GraphComponentLabel, text: str) -> None:
super().__init__(text)
self.document = document
self.graph_id = graph_id
self.component_id = component_id
database = document._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
self.old_label = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else None
self.new_label = deepcopy(label)
def redo(self) -> None:
self.document._set_graph_component_label(self.graph_id, self.component_id, self.new_label)
def undo(self) -> None:
self.document._set_graph_component_label(self.graph_id, self.component_id, self.old_label)

View File

@@ -0,0 +1,21 @@
from PySide6.QtGui import QUndoCommand
from bedit_core.models import ComponentID
class MoveGraphComponentCommand(QUndoCommand):
def __init__(self, document: object, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int]) -> None:
super().__init__("Move graph component")
self.document = document
self.graph_id = graph_id
self.component_id = component_id
database = document._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
self.old_position = graph.component_positions.get(component_id) if graph is not None else None
self.new_position = position
def redo(self) -> None:
self.document._set_graph_component_position(self.graph_id, self.component_id, self.new_position)
def undo(self) -> None:
self.document._set_graph_component_position(self.graph_id, self.component_id, self.old_position)

View File

@@ -0,0 +1,19 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_gui.models import PortMetadataDatabase
class ChangePortMetadataDatabaseCommand(QUndoCommand):
def __init__(self, document: object, database: PortMetadataDatabase) -> None:
super().__init__("Change port metadata")
self.document = document
self.old_database = document.stored_port_metadata_database()
self.new_database = deepcopy(database)
def redo(self) -> None:
self.document._set_port_metadata_database(self.new_database)
def undo(self) -> None:
self.document._set_port_metadata_database(self.old_database)

View File

@@ -8,6 +8,7 @@ from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document
from bedit_gui.services.clipboard import ClipboardService
from bedit_gui.services.component_clipboard import export_components, import_components
from bedit_gui.views.graph_editor_widget import GraphEditorWidget
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
@@ -28,6 +29,9 @@ class ClipboardHandler(QObject):
def can_paste(self) -> bool:
return False
def can_delete(self) -> bool:
return False
def copy(self) -> None:
pass
@@ -37,6 +41,9 @@ class ClipboardHandler(QObject):
def paste(self) -> None:
pass
def delete(self) -> None:
pass
class TextClipboardHandler(ClipboardHandler):
def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
@@ -106,6 +113,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
def can_paste(self) -> bool:
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
def can_delete(self) -> bool:
return bool(self._selected_components())
def copy(self) -> None:
components = self._selected_components()
if components:
@@ -123,11 +133,14 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
if target is None or payload is None:
return
try:
components, icons = import_components(payload)
components, icons, port_metadata = import_components(payload)
except (TypeError, ValueError) as exc:
QMessageBox.critical(self.tree, "Could not paste components", str(exc))
return
self.document.paste_components(target, components, icons)
self.document.paste_components(target, components, icons, port_metadata)
def delete(self) -> None:
self.document.delete_components(self._selected_components())
def _write_components(self, components: list[Component]) -> None:
payload = export_components(self.document, components)
@@ -162,6 +175,87 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
return None
class GraphEditorClipboardHandler(ClipboardHandler):
def __init__(self, document: Document, editor: GraphEditorWidget, clipboard: ClipboardService, parent: QObject | None = None) -> None:
super().__init__(parent)
self.document = document
self.editor = editor
self.clipboard = clipboard
editor.scene.selectionChanged.connect(self.availability_changed)
editor.component_drop_requested.connect(self.drop_components)
def owns_focus(self, widget: QWidget) -> bool:
return widget is self.editor or self.editor.isAncestorOf(widget)
def can_copy(self) -> bool:
return bool(self._selected_components())
def can_cut(self) -> bool:
return self.can_copy()
def can_paste(self) -> bool:
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
def can_delete(self) -> bool:
return bool(self._selected_components() or self.editor.selected_connection_ids())
def copy(self) -> None:
components = self._selected_components()
if components:
self._write_components(components)
def cut(self) -> None:
components = self._selected_components()
if components:
self._write_components(components)
self.document.delete_components(components)
def paste(self) -> None:
graph_component = self.editor.component()
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
if graph_component is None or not isinstance(graph_component.implementation, GraphImplementation) or payload is None:
return
x, y = self.editor.paste_position()
self._paste_payload(graph_component, payload, (x, y))
def drop_components(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None:
self._paste_payload(graph_component, payload, position)
def _paste_payload(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None:
try:
components, icons, port_metadata = import_components(payload)
except (TypeError, ValueError) as exc:
QMessageBox.critical(self.editor, "Could not paste components", str(exc))
return
x, y = position
spacing = self.editor.snap_to_grid_size * 4
positions = {component_id: (x + index * spacing, y + index * spacing) for index, component_id in enumerate(components)}
self.document.paste_graph_components(graph_component, components, icons, positions, port_metadata)
def delete(self) -> None:
components = self._selected_components()
connection_ids = self.editor.selected_connection_ids()
if components:
self.document.delete_components(components)
graph_component = self.editor.component()
if graph_component is not None and connection_ids:
self.document.delete_graph_connections(graph_component, connection_ids)
def _write_components(self, components: list[Component]) -> None:
payload = export_components(self.document, components)
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
def _selected_components(self) -> list[Component]:
target = self._target()
if target is None:
return []
return [target[component_id] for component_id in self.editor.selected_component_ids() if component_id in target]
def _target(self) -> dict | None:
component = self.editor.component()
return component.implementation.graph.components if component is not None and isinstance(component.implementation, GraphImplementation) else None
class ClipboardController(QObject):
def __init__(self, window: QMainWindow, clipboard: ClipboardService, handlers: list[ClipboardHandler]) -> None:
super().__init__(window)
@@ -171,6 +265,7 @@ class ClipboardController(QObject):
window.ui.actionCopy.triggered.connect(self.copy)
window.ui.actionCut.triggered.connect(self.cut)
window.ui.actionPaste.triggered.connect(self.paste)
window.ui.actionDelete.triggered.connect(self.delete)
application = QApplication.instance()
application.focusChanged.connect(self.update_actions)
application.installEventFilter(self)
@@ -209,8 +304,15 @@ class ClipboardController(QObject):
handler.paste()
self.update_actions()
def delete(self) -> None:
handler = self.active_handler()
if handler is not None and handler.can_delete():
handler.delete()
self.update_actions()
def update_actions(self, *_args: object) -> None:
handler = self.active_handler()
self.window.ui.actionCopy.setEnabled(handler is not None and handler.can_copy())
self.window.ui.actionCut.setEnabled(handler is not None and handler.can_cut())
self.window.ui.actionPaste.setEnabled(handler is not None and handler.can_paste())
self.window.ui.actionDelete.setEnabled(handler is not None and handler.can_delete())

View File

@@ -2,26 +2,27 @@ from collections.abc import Callable
from functools import partial
from typing import Protocol
from PySide6.QtCore import QEvent, QObject, QPoint, QSize, Qt
from PySide6.QtCore import QEvent, QItemSelectionModel, QObject, QPoint, QSize, Qt
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
from bedit_core.models import Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document
from bedit_gui.models import Icon
from bedit_gui.models import Graph, GraphComponentLabel, Icon, PortMetadata
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
from bedit_gui.views.icon_editor_window import IconEditorWindow
from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
from bedit_gui.utils.icon import render_icon
from bedit_gui.utils.icon import render_fitted_icon
ICON_SIZE = QSize(32, 32)
ICON_SIZE = QSize(16, 16)
class InterfaceEditorLike(Protocol):
def exec(self) -> int: ...
def ports(self) -> dict[PortID, Port]: ...
def port_metadata(self) -> dict[PortID, PortMetadata]: ...
class ParamEditorLike(Protocol):
def exec(self) -> int: ...
@@ -29,7 +30,7 @@ class ParamEditorLike(Protocol):
InterfaceEditorFactory = Callable[
[dict[PortID, Port], MainWindow],
[dict[PortID, Port], dict[PortID, PortMetadata], MainWindow],
InterfaceEditorLike,
]
@@ -60,12 +61,23 @@ class DocumentTreeController(QObject):
window.ui.documentTree.setModel(self.model)
window.ui.documentTree.selectionModel().selectionChanged.connect(self._selection_changed)
window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text)
window.equation_editor.port_metadata_change_requested.connect(self._change_equation_port_metadata)
document.model_changed.connect(self._on_document_changed)
document.icon_changed.connect(self._on_icon_changed)
document.port_metadata_database_changed.connect(self._on_port_metadata_database_changed)
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
document.graph_component_label_changed.connect(self._on_graph_component_label_changed)
document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed)
document.equation_text_changed.connect(self._on_equation_text_changed)
self.model.rename_document_requested.connect(self.document.rename)
self.model.rename_component_requested.connect(self.document.rename_component)
window.ui.actionDelete.triggered.connect(self.delete_selected_component)
window.graph_editor.component_moves_requested.connect(self.document.move_graph_components)
window.graph_editor.component_label_move_requested.connect(self.document.move_graph_component_label)
window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu)
window.graph_editor.component_open_requested.connect(self._open_graph_component)
window.graph_editor.connection_points_change_requested.connect(self.document.change_graph_connection_points)
window.graph_editor.connection_add_requested.connect(self.document.add_graph_connection)
window.graph_editor.connections_delete_requested.connect(self.document.delete_graph_connections)
# Add deselection with esc to this widget
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
@@ -75,11 +87,11 @@ class DocumentTreeController(QObject):
window.ui.documentTree.setHeaderHidden(True)
window.ui.documentTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
window.ui.documentTree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
window.ui.documentTree.setIconSize(QSize(48, 48))
window.ui.documentTree.setIconSize(QSize(24, 24))
window.ui.documentTree.header().setStretchLastSection(False)
window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
window.ui.documentTree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
window.ui.documentTree.setColumnWidth(1, 56)
window.ui.documentTree.setColumnWidth(1, 28)
self._tree_viewport = window.ui.documentTree.viewport()
self._tree_viewport.installEventFilter(self)
@@ -96,13 +108,14 @@ class DocumentTreeController(QObject):
def _on_document_changed(self, model: CoreDocument) -> None:
"""Rebuild the tree whenever New/Open replaces the core document."""
self._show_component(None)
displayed_component = self.window.graph_editor.component() or self.window.equation_editor.component()
self.model.set_document(model)
self._components = {}
self._collect_components(model.root)
for component_id, component in self._components.items():
icon = self.document.component_icon(component_id)
self.model.set_component_icon(component_id, render_icon(icon, component.interface.ports, ICON_SIZE))
self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE))
self._show_component(displayed_component if any(component is displayed_component for component in self._components.values()) else None)
# Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes.
@@ -115,13 +128,17 @@ class DocumentTreeController(QObject):
def _show_component(self, component: Component | None) -> None:
if component is not None and isinstance(component.implementation, EquationImplementation):
self.window.equation_editor.set_component(component)
port_metadata = {port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports}
self.window.equation_editor.set_component(component, port_metadata)
self.window.equation_editor.show()
else:
self.window.equation_editor.set_component(None)
self.window.equation_editor.hide()
if component is not None and isinstance(component.implementation, GraphImplementation):
self.window.graph_editor.set_component(component)
graph = self.document.graph_database().graphs.get(self.document.component_id(component), Graph())
icons = {component_id: self.document.component_icon(component_id) for component_id in component.implementation.graph.components}
port_metadata = {port_id: self.document.port_metadata(port_id) for child in component.implementation.graph.components.values() for port_id in child.interface.ports}
self.window.graph_editor.set_component(component, graph, icons, port_metadata)
self.window.graph_editor.show()
else:
self.window.graph_editor.set_component(None)
@@ -135,7 +152,36 @@ class DocumentTreeController(QObject):
component = self._components.get(component_id)
if component is None:
return
self.model.set_component_icon(component_id, render_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
self.model.set_component_icon(component_id, render_fitted_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
graph_component = self.window.graph_editor.component()
if graph_component is not None and isinstance(graph_component.implementation, GraphImplementation) and component_id in graph_component.implementation.graph.components:
self._show_component(graph_component)
def _on_port_metadata_database_changed(self, _database: object) -> None:
graph_component = self.window.graph_editor.component()
if graph_component is not None:
self._show_component(graph_component)
equation_component = self.window.equation_editor.component()
if equation_component is not None:
self.window.equation_editor.refresh_port_metadata({port_id: self.document.port_metadata(port_id) for port_id in equation_component.interface.ports})
def _change_equation_port_metadata(self, component: Component, port_metadata: dict[PortID, PortMetadata]) -> None:
self.document.update_component_ports(component, component.interface.ports, port_metadata)
def _on_graph_component_position_changed(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None:
graph_component = self.window.graph_editor.component()
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
self.window.graph_editor.set_component_position(component_id, position)
def _on_graph_component_label_changed(self, graph_id: ComponentID, component_id: ComponentID, label: object) -> None:
graph_component = self.window.graph_editor.component()
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
self.window.graph_editor.set_component_label(component_id, label if isinstance(label, GraphComponentLabel) else None)
def _on_graph_connection_points_changed(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
graph_component = self.window.graph_editor.component()
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
self.window.graph_editor.set_connection_points(connection_id, points)
def _collect_components(self, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items():
@@ -145,31 +191,66 @@ class DocumentTreeController(QObject):
def _show_context_menu(self, position: QPoint) -> None:
index = self.window.ui.documentTree.indexAt(position)
component = self.model.value(index)
if not isinstance(component, Component):
return
value = self.model.value(index)
global_position = self.window.ui.documentTree.viewport().mapToGlobal(position)
if isinstance(value, CoreDocument):
self._show_root_context_menu(global_position)
elif isinstance(value, Component):
self._show_component_context_menu(value, global_position)
def _show_root_context_menu(self, global_position: QPoint) -> None:
menu = QMenu(self.window.ui.documentTree)
add_graph_component = menu.addAction("Add Graph Component")
add_equation_component = menu.addAction("Add Equation Component")
selected = menu.exec(global_position)
if selected is add_graph_component:
self.document.add_empty_root_graph_component()
elif selected is add_equation_component:
self.document.add_empty_root_equation_component()
def _show_graph_component_context_menu(self, component_id: ComponentID, global_position: QPoint) -> None:
component = self._components.get(component_id)
if component is not None:
self._show_component_context_menu(component, global_position, component_id)
def _open_graph_component(self, component_id: ComponentID) -> None:
index = self.model.component_index(component_id)
if index.isValid():
self.window.ui.documentTree.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
self.window.ui.documentTree.scrollTo(index)
def _show_component_context_menu(self, component: Component, global_position: QPoint, graph_component_id: ComponentID | None = None) -> None:
menu = QMenu(self.window.ui.documentTree)
edit_interface = menu.addAction("Edit Interface")
edit_params = menu.addAction("Edit Parameters")
edit_icon = menu.addAction("Edit Icon")
show_label = None
if graph_component_id is not None:
show_label = menu.addAction("Show Label")
show_label.setCheckable(True)
show_label.setChecked(self.window.graph_editor.component_label_visible(graph_component_id))
menu.addSeparator()
add_graph_component = menu.addAction("Add Graph Component")
add_equation_component = menu.addAction("Add Equation Component")
menu.addSeparator()
add_graph_component = None
add_equation_component = None
if isinstance(component.implementation, GraphImplementation):
add_graph_component = menu.addAction("Add Graph Component")
add_equation_component = menu.addAction("Add Equation Component")
menu.addSeparator()
delete_component = menu.addAction("Delete Component")
selected = menu.exec(
self.window.ui.documentTree.viewport().mapToGlobal(position)
)
selected = menu.exec(global_position)
if selected is edit_interface:
self._edit_interface(component)
elif selected is edit_params:
self._edit_params(component)
elif selected is edit_icon:
self._edit_icon(component)
elif selected is add_graph_component:
elif show_label is not None and selected is show_label:
graph_component = self.window.graph_editor.component()
if graph_component is not None:
self.document.set_graph_component_label_visible(graph_component, graph_component_id, show_label.isChecked())
elif add_graph_component is not None and selected is add_graph_component:
self._add_graph_component(component)
elif selected is add_equation_component:
elif add_equation_component is not None and selected is add_equation_component:
self._add_equation_component(component)
elif selected is delete_component:
self._delete_component(component)
@@ -177,10 +258,11 @@ class DocumentTreeController(QObject):
def _edit_interface(self, component: Component) -> None:
dialog = self.interface_editor_factory(
component.interface.ports,
{port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports},
self.window,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.document.update_component_ports(component, dialog.ports())
self.document.update_component_ports(component, dialog.ports(), dialog.port_metadata())
def _edit_params(self, component: Component) -> None:
dialog = self.param_editor_factory(

View File

@@ -0,0 +1,81 @@
from PySide6.QtCore import QObject, QSize, Qt
from PySide6.QtWidgets import QAbstractItemView, QHeaderView
from bedit_core.models import Component, ComponentID, GraphImplementation, PortID
from bedit_gui.models import Icon, IconDatabase, PortMetadata, PortMetadataDatabase
from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.services.component_clipboard import export_component_data
from bedit_gui.services.libraries import load_library_documents
from bedit_gui.utils.icon import render_fitted_icon
from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models.library_tree_model import LibraryTreeModel
ICON_SIZE = QSize(32, 32)
class LibraryController(QObject):
def __init__(self, window: MainWindow, settings: ApplicationSettings) -> None:
super().__init__(window)
self.window = window
self.settings = settings
self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon], dict[PortID, PortMetadata]]] = {}
self.model = LibraryTreeModel(self._component_payload)
tree = window.ui.libraryTree
tree.setModel(self.model)
tree.setHeaderHidden(True)
tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
tree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
tree.setDragEnabled(True)
tree.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
tree.setDefaultDropAction(Qt.DropAction.CopyAction)
tree.setIconSize(QSize(48, 48))
tree.header().setStretchLastSection(False)
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
tree.setColumnWidth(1, 56)
window.ui.actionReload_Libraries.triggered.connect(self.reload)
self.reload()
def reload(self) -> None:
libraries = load_library_documents(self.settings.library_paths)
self._component_sources = {}
for library in libraries:
database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None
icons = database.icons if isinstance(database, IconDatabase) else {}
metadata_database = library.document.metadata.get("port_metadata_database") if library.document.metadata is not None else None
port_metadata = metadata_database.ports if isinstance(metadata_database, PortMetadataDatabase) else {}
self._collect_component_sources(library.document.root, icons, port_metadata)
self.model.set_documents([library.document for library in libraries])
for library in libraries:
database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None
icons = database.icons if isinstance(database, IconDatabase) else {}
self._set_component_icons(library.document.root, icons)
self.window.ui.libraryTree.expandAll()
def _component_payload(self, components: list[Component]) -> dict:
roots = {}
icons = {}
port_metadata = {}
for component in components:
source = self._component_sources.get(id(component))
if source is None:
continue
component_id, source_icons, source_port_metadata = source
roots[component_id] = component
icons.update(source_icons)
port_metadata.update(source_port_metadata)
return export_component_data(roots, icons, port_metadata)
def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata]) -> None:
for component_id, component in components.items():
self._component_sources[id(component)] = (component_id, icons, port_metadata)
if isinstance(component.implementation, GraphImplementation):
self._collect_component_sources(component.implementation.graph.components, icons, port_metadata)
def _set_component_icons(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
for component_id, component in components.items():
self.model.set_component_icon(component_id, render_fitted_icon(icons.get(component_id, Icon()), component.interface.ports, ICON_SIZE))
if isinstance(component.implementation, GraphImplementation):
self._set_component_icons(component.implementation.graph.components, icons)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QAbstractItemView
from bedit_gui.services.application_logging import configure_logging
from bedit_gui.views.main_window import MainWindow
@@ -49,4 +50,6 @@ class LogController(QObject):
self.emitter.message.connect(self.model.append)
self.model.rowsInserted.connect(window.ui.listView.scrollToBottom)
window.ui.listView.setModel(self.model)
window.ui.listView.setWordWrap(True)
window.ui.listView.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
configure_logging(self.handler, level)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
from PySide6.QtCore import QObject
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QDialog
from bedit_gui.services.application_logging import get_logger, set_log_level
@@ -18,15 +18,23 @@ class SettingsDialogLike(Protocol):
@property
def log_level(self) -> int: ...
@property
def snap_to_grid_size(self) -> int: ...
@property
def library_paths(self) -> list[str]: ...
def exec(self) -> int: ...
SettingsDialogFactory = Callable[[int, MainWindow], SettingsDialogLike]
SettingsDialogFactory = Callable[[int, int, list[str], MainWindow], SettingsDialogLike]
class SettingsController(QObject):
"""Opens the settings dialog and applies accepted preferences."""
library_paths_changed = Signal()
def __init__(
self,
window: MainWindow,
@@ -41,10 +49,16 @@ class SettingsController(QObject):
window.ui.actionSettings.triggered.connect(self.open_settings)
def open_settings(self) -> None:
dialog = self.dialog_factory(self.settings.log_level, self.window)
dialog = self.dialog_factory(self.settings.log_level, self.settings.snap_to_grid_size, self.settings.library_paths, self.window)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
old_library_paths = self.settings.library_paths
self.settings.log_level = dialog.log_level
self.settings.snap_to_grid_size = dialog.snap_to_grid_size
self.settings.library_paths = dialog.library_paths
self.window.graph_editor.set_snap_to_grid_size(dialog.snap_to_grid_size)
set_log_level(dialog.log_level)
if self.settings.library_paths != old_library_paths:
self.library_paths_changed.emit()
logger.info("Application settings updated")

View File

@@ -62,9 +62,10 @@ class SimulationController(QObject):
self.window.statusBar().showMessage(f"Compiling {component_path}")
logger.info("Compiling model: %s", component_path)
try:
self.document.infer_causality(component)
build = self.compiler(component, build_directory)
except (OSError, RuntimeError, ValueError) as exc:
logger.exception("Could not compile model %s", component_path)
logger.error("Could not compile model %s:\n%s", component_path, exc)
QMessageBox.critical(self.window, "Could not compile", str(exc))
self.window.statusBar().showMessage("Compilation failed")
return None

View File

@@ -6,17 +6,24 @@ from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack
from bedit_core.models import ID, Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import ID, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument
from bedit_core.bondgraph import causality_inference
from bedit_gui.commands.causality_command import ChangeCausalityCommand, causality_state
from bedit_gui.commands.change_icon_command import ChangeIconCommand
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand
from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnectionPointsCommand
from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand
from bedit_gui.commands.graph_label_command import ChangeGraphComponentLabelCommand
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
from bedit_gui.commands.port_metadata_command import ChangePortMetadataDatabaseCommand
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
from bedit_gui.commands.rename_component_command import RenameComponentCommand
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents
from bedit_gui.models import Icon, IconDatabase, Simulation, SimulationDatabase
from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, PortMetadata, PortMetadataDatabase, Simulation, SimulationDatabase
from bedit_gui.services import document_files
@@ -29,6 +36,10 @@ class Document(QObject):
icon_changed = Signal(object, object)
equation_text_changed = Signal(object, str)
simulation_database_changed = Signal(object)
port_metadata_database_changed = Signal(object)
graph_component_position_changed = Signal(object, object, object)
graph_component_label_changed = Signal(object, object, object)
graph_connection_points_changed = Signal(object, object, object)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -98,6 +109,12 @@ class Document(QObject):
def rename_component(self, component: Component, name: str) -> None:
self.undo_stack.push(RenameComponentCommand(self, component, name))
def infer_causality(self, component: Component) -> None:
inferred_component = deepcopy(component)
causality_inference(inferred_component)
if causality_state(component) != causality_state(inferred_component):
self.undo_stack.push(ChangeCausalityCommand(self, component, inferred_component))
def component_id(self, component: Component) -> ComponentID:
def find(components: dict[ComponentID, Component]) -> ComponentID | None:
for component_id, candidate in components.items():
@@ -121,6 +138,152 @@ class Document(QObject):
def component_icon(self, component_id: ComponentID) -> Icon:
return self.stored_component_icon(component_id) or Icon()
def graph_database(self) -> GraphDatabase:
database = self._graph_database(False)
return deepcopy(database) if database is not None else GraphDatabase()
def stored_port_metadata_database(self) -> PortMetadataDatabase | None:
database = self._port_metadata_database(False)
return deepcopy(database) if database is not None else None
def port_metadata_database(self) -> PortMetadataDatabase:
return self.stored_port_metadata_database() or PortMetadataDatabase()
def port_metadata(self, port_id: PortID) -> PortMetadata:
return deepcopy(self.port_metadata_database().ports.get(port_id, PortMetadata()))
def _set_port_metadata_database(self, database: PortMetadataDatabase | None) -> None:
if database is None or not database.ports:
if self.model.metadata is not None:
self.model.metadata.pop("port_metadata_database", None)
else:
if self.model.metadata is None:
self.model.metadata = {}
self.model.metadata["port_metadata_database"] = deepcopy(database)
self.port_metadata_database_changed.emit(self.stored_port_metadata_database())
def _port_metadata_database(self, create: bool) -> PortMetadataDatabase | None:
metadata = self.model.metadata
value = metadata.get("port_metadata_database") if metadata is not None else None
if isinstance(value, PortMetadataDatabase):
return value
if not create:
return None
if metadata is None:
metadata = {}
self.model.metadata = metadata
database = PortMetadataDatabase()
metadata["port_metadata_database"] = database
return database
def move_graph_component(self, graph_component: Component, component_id: ComponentID, position: tuple[int, int]) -> None:
graph_id = self.component_id(graph_component)
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is None or graph.component_positions.get(component_id) != position:
self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position))
def move_graph_components(self, graph_component: Component, positions: dict[ComponentID, tuple[int, int]]) -> None:
graph_id = self.component_id(graph_component)
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
changed = {component_id: position for component_id, position in positions.items() if graph is None or graph.component_positions.get(component_id) != position}
if not changed:
return
self.undo_stack.beginMacro("Move graph components" if len(changed) > 1 else "Move graph component")
for component_id, position in changed.items():
self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position))
self.undo_stack.endMacro()
def _set_graph_component_position(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None:
if position is None:
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is not None:
graph.component_positions.pop(component_id, None)
else:
database = self._graph_database(True)
graph = database.graphs.setdefault(graph_id, Graph())
graph.component_positions[component_id] = position
self.graph_component_position_changed.emit(graph_id, component_id, position)
def move_graph_component_label(self, graph_component: Component, component_id: ComponentID, relative_position: tuple[int, int]) -> None:
graph_id = self.component_id(graph_component)
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
current = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else GraphComponentLabel()
if current.relative_position != relative_position:
current.relative_position = relative_position
self.undo_stack.push(ChangeGraphComponentLabelCommand(self, graph_id, component_id, current, "Move component label"))
def set_graph_component_label_visible(self, graph_component: Component, component_id: ComponentID, visible: bool) -> None:
graph_id = self.component_id(graph_component)
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
current = deepcopy(graph.component_labels.get(component_id)) if graph is not None and component_id in graph.component_labels else GraphComponentLabel()
if current.visible != visible:
current.visible = visible
self.undo_stack.push(ChangeGraphComponentLabelCommand(self, graph_id, component_id, current, "Show component label" if visible else "Hide component label"))
def _set_graph_component_label(self, graph_id: ComponentID, component_id: ComponentID, label: GraphComponentLabel | None) -> None:
if label is None:
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is not None:
graph.component_labels.pop(component_id, None)
else:
database = self._graph_database(True)
graph = database.graphs.setdefault(graph_id, Graph())
graph.component_labels[component_id] = deepcopy(label)
self.graph_component_label_changed.emit(graph_id, component_id, deepcopy(label))
def _graph_database(self, create: bool) -> GraphDatabase | None:
metadata = self.model.metadata
value = metadata.get("graph_database") if metadata is not None else None
if isinstance(value, dict):
value = GraphDatabase.from_data(value)
metadata["graph_database"] = value
if isinstance(value, GraphDatabase):
return value
if not create:
return None
if metadata is None:
metadata = {}
self.model.metadata = metadata
database = GraphDatabase()
metadata["graph_database"] = database
return database
def change_graph_connection_points(self, graph_component: Component, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
graph_id = self.component_id(graph_component)
self.undo_stack.push(ChangeGraphConnectionPointsCommand(self, graph_id, connection_id, points, text))
def add_graph_connection(self, graph_component: Component, connection: Connection) -> None:
self.undo_stack.push(AddGraphConnectionCommand(self, graph_component, connection))
def delete_graph_connections(self, graph_component: Component, connection_ids: list[ConnectionID]) -> None:
if not isinstance(graph_component.implementation, GraphImplementation):
return
connection_ids = [connection_id for connection_id in connection_ids if connection_id in graph_component.implementation.graph.connections]
if not connection_ids:
return
self.undo_stack.beginMacro("Delete connections")
for connection_id in connection_ids:
self.undo_stack.push(DeleteGraphConnectionCommand(self, graph_component, connection_id))
self.undo_stack.endMacro()
def _set_graph_connection_points(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
if points is None:
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is not None:
graph.connections.pop(connection_id, None)
else:
database = self._graph_database(True)
graph = database.graphs.setdefault(graph_id, Graph())
graph.connections[connection_id] = GraphConnection(points=list(points))
self.graph_connection_points_changed.emit(graph_id, connection_id, points)
def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
self.undo_stack.push(ChangeIconCommand(self, component_id, icon))
@@ -194,7 +357,7 @@ class Document(QObject):
metadata["icon_database"] = database
return database
def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None:
def update_component_ports(self, component: Component, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
current = component.interface.ports
removed = [
RemovePortCommand(self, component, port_id)
@@ -210,12 +373,23 @@ class Document(QObject):
if current[port_id] != ports[port_id]
]
commands = [*removed, *added, *changed]
if not commands:
database = self.port_metadata_database()
component_port_ids = set(current) | set(ports)
for port_id in component_port_ids:
metadata = port_metadata.get(port_id, PortMetadata()) if port_metadata is not None and port_id in ports else PortMetadata()
if metadata == PortMetadata():
database.ports.pop(port_id, None)
else:
database.ports[port_id] = deepcopy(metadata)
metadata_changed = database != self.port_metadata_database()
if not commands and not metadata_changed:
return
self.undo_stack.beginMacro("Edit interface")
for command in commands:
self.undo_stack.push(command)
if metadata_changed:
self.undo_stack.push(ChangePortMetadataDatabaseCommand(self, database))
self.undo_stack.endMacro()
@@ -254,12 +428,22 @@ class Document(QObject):
command.component.name = self._unique_component_name(component.implementation.graph.components, command.component.name)
self.undo_stack.push(command)
def add_empty_root_graph_component(self) -> None:
command = AddEmptyGraphComponent(self, self.model.root)
command.component.name = self._unique_component_name(self.model.root, command.component.name)
self.undo_stack.push(command)
def add_empty_equation_component(self, component: Component) -> None:
if isinstance(component.implementation, GraphImplementation):
command = AddEmptyEquationComponent(self, component)
command.component.name = self._unique_component_name(component.implementation.graph.components, command.component.name)
self.undo_stack.push(command)
def add_empty_root_equation_component(self) -> None:
command = AddEmptyEquationComponent(self, self.model.root)
command.component.name = self._unique_component_name(self.model.root, command.component.name)
self.undo_stack.push(command)
def delete_component(self, component: Component) -> None:
self.undo_stack.push(DeleteComponent(self, component))
@@ -271,14 +455,24 @@ class Document(QObject):
self.undo_stack.push(DeleteComponent(self, component))
self.undo_stack.endMacro()
def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
if not components:
return
names = {component.name for component in target.values()}
for component in components.values():
component.name = self._unique_name(names, component.name)
names.add(component.name)
self.undo_stack.push(PasteComponents(self, target, components, icons))
self.undo_stack.push(PasteComponents(self, target, components, icons, port_metadata=port_metadata))
def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
if not isinstance(graph_component.implementation, GraphImplementation) or not components:
return
target = graph_component.implementation.graph.components
names = {component.name for component in target.values()}
for component in components.values():
component.name = self._unique_name(names, component.name)
names.add(component.name)
self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions, port_metadata))
@staticmethod
def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str:

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from bedit_core.models import ComponentID, ID, PortID
from bedit_core.models import ComponentID, ConnectionID, ID, PortID
class ShapeID(ID):
@@ -24,6 +24,8 @@ class Shape:
def from_data(cls, data: Mapping[str, Any]) -> Shape:
if cls is Shape and data.get("type") == "rectangle":
return Rectangle.from_data(data)
if cls is Shape and data.get("type") == "ellipse":
return Ellipse.from_data(data)
if cls is Shape and data.get("type") == "text":
return Text.from_data(data)
if cls is Shape and data.get("type") == "line":
@@ -77,6 +79,24 @@ class Rectangle(Shape):
def to_data(self) -> dict[str, Any]:
return {**super().to_data(), "width": self.width, "height": self.height, "line_type": self.line_type.value, "line_thickness": self.line_thickness, "corner_radius": self.corner_radius, "line_color": self.line_color, "fill_color": self.fill_color}
@dataclass
class Ellipse(Shape):
type: str = field(init=False, default="ellipse")
width: float = 32.0
height: float = 32.0
line_type: LineType = LineType.SOLID
line_thickness: float = 1.0
line_color: str = "#000000ff"
fill_color: str = "#ffffff00"
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Ellipse:
pos = data.get("pos", [0, 0])
return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), width=float(data.get("width", 100.0)), height=float(data.get("height", 100.0)), line_type=LineType(data.get("line_type", "solid")), line_thickness=float(data.get("line_thickness", 1.0)), line_color=str(data.get("line_color", "#000000ff")), fill_color=str(data.get("fill_color", "#ffffff00")))
def to_data(self) -> dict[str, Any]:
return {**super().to_data(), "width": self.width, "height": self.height, "line_type": self.line_type.value, "line_thickness": self.line_thickness, "line_color": self.line_color, "fill_color": self.fill_color}
@dataclass
class Text(Shape):
type: str = field(init=False, default="text")
@@ -123,6 +143,91 @@ class IconDatabase:
def to_data(self) -> dict[str, Any]:
return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}}
@dataclass
class PortMetadata:
connection_annotation: str | None = None
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> PortMetadata:
annotation = data.get("connection_annotation")
return cls(connection_annotation=str(annotation) if annotation else None)
def to_data(self) -> dict[str, Any]:
return {"connection_annotation": self.connection_annotation}
@dataclass
class PortMetadataDatabase:
format_version: int = 1
ports: dict[PortID, PortMetadata] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> PortMetadataDatabase:
ports = {PortID(key): PortMetadata.from_data(value) for key, value in data.get("ports", {}).items()}
return cls(format_version=int(data.get("format_version", 1)), ports=ports)
def to_data(self) -> dict[str, Any]:
return {"format_version": self.format_version, "ports": {str(key): metadata.to_data() for key, metadata in self.ports.items()}}
@dataclass
class GraphConnection:
points: list[tuple[int, int]] = field(default_factory=list)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> GraphConnection:
return cls(points=[(int(point[0]), int(point[1])) for point in data.get("points", [])])
def to_data(self) -> dict[str, Any]:
return {"points": [list(point) for point in self.points]}
@dataclass
class GraphComponentLabel:
relative_position: tuple[int, int] = (0, 8)
visible: bool = True
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> GraphComponentLabel:
position = data.get("relative_position", [0, 8])
return cls(relative_position=(int(position[0]), int(position[1])), visible=bool(data.get("visible", True)))
def to_data(self) -> dict[str, Any]:
return {"relative_position": list(self.relative_position), "visible": self.visible}
@dataclass
class Graph:
shapes: dict[ShapeID, Shape] = field(default_factory=dict)
component_positions: dict[ComponentID, tuple[int, int]] = field(default_factory=dict)
connections: dict[ConnectionID, GraphConnection] = field(default_factory=dict)
component_labels: dict[ComponentID, GraphComponentLabel] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Graph:
shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()}
component_positions = {ComponentID(key): (int(value[0]), int(value[1])) for key, value in data.get("component_positions", {}).items()}
component_labels = {ComponentID(key): GraphComponentLabel.from_data(value) for key, value in data.get("component_labels", {}).items()}
connections = {ConnectionID(key): GraphConnection.from_data(value) for key, value in data.get("connections", {}).items()}
return cls(shapes=shapes, component_positions=component_positions, component_labels=component_labels, connections=connections)
def to_data(self) -> dict[str, Any]:
return {
"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()},
"component_positions": {str(key): list(position) for key, position in self.component_positions.items()},
"component_labels": {str(key): label.to_data() for key, label in self.component_labels.items()},
"connections": {str(key): connection.to_data() for key, connection in self.connections.items()},
}
@dataclass
class GraphDatabase:
format_version: int = 1
graphs: dict[ComponentID, Graph] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> GraphDatabase:
graphs = {ComponentID(key): Graph.from_data(value) for key, value in data.get("graphs", {}).items()}
return cls(format_version=int(data.get("format_version", 1)), graphs=graphs)
def to_data(self) -> dict[str, Any]:
return {"format_version": self.format_version, "graphs": {str(key): graph.to_data() for key, graph in self.graphs.items()}}
class SimulationMethod(Enum):
DASSL = "dassl"

View File

@@ -10,6 +10,9 @@ class ApplicationSettings:
LOG_LEVEL_KEY = "logging/level"
DEFAULT_LOG_LEVEL = logging.INFO
SNAP_TO_GRID_SIZE_KEY = "graph/snap_to_grid_size"
DEFAULT_SNAP_TO_GRID_SIZE = 4
LIBRARY_PATHS_KEY = "libraries/paths"
def __init__(self, settings: QSettings | None = None) -> None:
self._settings = settings if settings is not None else QSettings()
@@ -26,6 +29,27 @@ class ApplicationSettings:
def log_level(self, level: int) -> None:
self._settings.setValue(self.LOG_LEVEL_KEY, level)
@property
def snap_to_grid_size(self) -> int:
return max(1, self._settings.value(self.SNAP_TO_GRID_SIZE_KEY, self.DEFAULT_SNAP_TO_GRID_SIZE, type=int))
@snap_to_grid_size.setter
def snap_to_grid_size(self, size: int) -> None:
if size < 1:
raise ValueError("snap-to-grid size must be positive")
self._settings.setValue(self.SNAP_TO_GRID_SIZE_KEY, size)
@property
def library_paths(self) -> list[str]:
value = self._settings.value(self.LIBRARY_PATHS_KEY, [])
if isinstance(value, str):
return [value]
return [str(path) for path in value] if isinstance(value, (list, tuple)) else []
@library_paths.setter
def library_paths(self, paths: list[str]) -> None:
self._settings.setValue(self.LIBRARY_PATHS_KEY, list(dict.fromkeys(paths)))
class SimulationApplicationSettings:
"""Typed access to persistent BEsim application settings."""

View File

@@ -6,9 +6,11 @@ from typing import Any
from PySide6.QtCore import QMimeData, QObject, Signal
from PySide6.QtGui import QClipboard, QGuiApplication
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
class ClipboardService(QObject):
COMPONENTS_MIME = "application/x-bedit-components+json"
COMPONENTS_MIME = COMPONENTS_MIME
changed = Signal()
def __init__(self, parent: QObject | None = None) -> None:

View File

@@ -6,24 +6,37 @@ from typing import Any
from bedit_core.models import Component, ComponentID, ConnectionID, Document, GraphImplementation, ID, ParameterID, PortID
from bedit_core.serialization.schema import document_from_data, document_to_data
from bedit_gui.documents import Document as GuiDocument
from bedit_gui.models import Icon, ShapeID
from bedit_gui.models import Icon, PortMetadata, ShapeID
FORMAT_VERSION = 1
COMPONENTS_MIME = "application/x-bedit-components+json"
def export_components(document: GuiDocument, components: list[Component]) -> dict[str, Any]:
roots = {document.component_id(component): component for component in components}
serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=roots))
component_ids = _all_component_ids(roots)
icons = {}
port_metadata = {}
for component_id in component_ids:
icon = document.stored_component_icon(component_id)
if icon is not None:
icons[str(component_id)] = icon.to_data()
return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icons}
icons[component_id] = icon
database = document.port_metadata_database()
for port_id in _all_port_ids(roots):
if port_id in database.ports:
port_metadata[port_id] = database.ports[port_id]
return export_component_data(roots, icons, port_metadata)
def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon]]:
def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> dict[str, Any]:
serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=components))
component_ids = set(_all_component_ids(components))
icon_data = {str(component_id): icon.to_data() for component_id, icon in icons.items() if component_id in component_ids}
metadata_data = {str(port_id): metadata.to_data() for port_id, metadata in (port_metadata or {}).items() if port_id in set(_all_port_ids(components))}
return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data, "port_metadata": metadata_data}
def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon], dict[PortID, PortMetadata]]:
if payload.get("format_version") != FORMAT_VERSION or payload.get("type") != "components":
raise ValueError("unsupported component clipboard format")
components = payload.get("components")
@@ -45,7 +58,11 @@ def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Compon
icon.shapes = {ShapeID(): shape for shape in icon.shapes.values()}
icon.port_positions = {port_map[port_id]: position for port_id, position in icon.port_positions.items() if port_id in port_map}
icons[new_component_id] = icon
return remapped, icons
metadata_data = payload.get("port_metadata", {})
if not isinstance(metadata_data, dict):
raise TypeError("component clipboard port metadata must be an object")
port_metadata = {port_map[PortID(old_id)]: PortMetadata.from_data(data) for old_id, data in metadata_data.items() if PortID(old_id) in port_map and isinstance(data, dict)}
return remapped, icons, port_metadata
def _remap_components(components: dict[ComponentID, Component], component_map: dict[ComponentID, ComponentID], port_map: dict[PortID, PortID]) -> dict[ComponentID, Component]:
@@ -80,3 +97,12 @@ def _all_component_ids(components: dict[ComponentID, Component]) -> list[Compone
if isinstance(component.implementation, GraphImplementation):
component_ids.extend(_all_component_ids(component.implementation.graph.components))
return component_ids
def _all_port_ids(components: dict[ComponentID, Component]) -> list[PortID]:
port_ids: list[PortID] = []
for component in components.values():
port_ids.extend(component.interface.ports)
if isinstance(component.implementation, GraphImplementation):
port_ids.extend(_all_port_ids(component.implementation.graph.components))
return port_ids

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from bedit_core.models import Document
from bedit_core.serialization import load as load_document
from bedit_core.serialization import save as save_document
from bedit_gui.models import IconDatabase, SimulationDatabase
from bedit_gui.models import GraphDatabase, IconDatabase, PortMetadataDatabase, SimulationDatabase
def load(path: str | Path) -> Document:
@@ -14,8 +14,12 @@ def load(path: str | Path) -> Document:
document = load_document(path)
if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict):
document.metadata["icon_database"] = IconDatabase.from_data(document.metadata["icon_database"])
if document.metadata is not None and isinstance(document.metadata.get("graph_database"), dict):
document.metadata["graph_database"] = GraphDatabase.from_data(document.metadata["graph_database"])
if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict):
document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"])
if document.metadata is not None and isinstance(document.metadata.get("port_metadata_database"), dict):
document.metadata["port_metadata_database"] = PortMetadataDatabase.from_data(document.metadata["port_metadata_database"])
return document
@@ -24,6 +28,10 @@ def save(document: Document, path: str | Path) -> None:
saved_document = deepcopy(document)
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase):
saved_document.metadata["icon_database"] = saved_document.metadata["icon_database"].to_data()
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("graph_database"), GraphDatabase):
saved_document.metadata["graph_database"] = saved_document.metadata["graph_database"].to_data()
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase):
saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data()
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("port_metadata_database"), PortMetadataDatabase):
saved_document.metadata["port_metadata_database"] = saved_document.metadata["port_metadata_database"].to_data()
save_document(saved_document, path)

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from bedit_core.models import Document
from bedit_gui.services import document_files
from bedit_gui.services.application_logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True)
class LoadedLibrary:
path: Path
document: Document
def list_library_files(library_paths: list[str]) -> list[Path]:
files = []
seen = set()
for configured_path in library_paths:
path = Path(configured_path).expanduser()
candidates = [path] if path.is_file() else sorted(path.rglob("*"), key=lambda candidate: str(candidate).casefold()) if path.is_dir() else []
for candidate in candidates:
if not candidate.is_file() or candidate.suffix.lower() not in (".beb", ".json"):
continue
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
files.append(resolved)
return files
def load_library_documents(library_paths: list[str]) -> list[LoadedLibrary]:
libraries = []
for path in list_library_files(library_paths):
try:
libraries.append(LoadedLibrary(path, document_files.load(path)))
except (KeyError, OSError, TypeError, ValueError) as exc:
logger.warning("Could not load library %s: %s", path, exc)
return libraries

View File

@@ -40,10 +40,9 @@
<property name="floatable">
<bool>false</bool>
</property>
<addaction name="actionSelect"/>
<addaction name="actionAddComponent"/>
<addaction name="actionAddConnection"/>
<addaction name="actionDelete"/>
<addaction name="actionZoomToFit"/>
<addaction name="actionMouseMode"/>
<addaction name="actionConnectionMode"/>
</widget>
</item>
<item>
@@ -51,48 +50,60 @@
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="dragMode">
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
</property>
<property name="renderHints">
<set>QPainter::RenderHint::Antialiasing</set>
</property>
<property name="dragMode">
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
</property>
</widget>
</item>
</layout>
<action name="actionSelect">
<action name="actionZoomToFit">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/zoom-original.png</normaloff>:/icons/icons/zoom-original.png</iconset>
</property>
<property name="text">
<string>Select</string>
<string>Zoom to Fit</string>
</property>
<property name="toolTip">
<string>Select graph items</string>
<string>Zoom canvas to fit</string>
</property>
</action>
<action name="actionAddComponent">
<action name="actionMouseMode">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-select.png</normaloff>:/icons/icons/edit-select.png</iconset>
</property>
<property name="text">
<string>Add Component</string>
<string>Mouse Mode</string>
</property>
<property name="toolTip">
<string>Add a component</string>
<string>Mouse Mode</string>
</property>
</action>
<action name="actionAddConnection">
<action name="actionConnectionMode">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/network-connect.png</normaloff>:/icons/icons/network-connect.png</iconset>
</property>
<property name="text">
<string>Add Connection</string>
<string>Connection Mode</string>
</property>
<property name="toolTip">
<string>Add a connection</string>
</property>
</action>
<action name="actionDelete">
<property name="text">
<string>Delete</string>
</property>
<property name="toolTip">
<string>Delete selected graph items</string>
<string>Connection Mode</string>
</property>
</action>
</widget>
<resources/>
<resources>
<include location="../../resources/resources.qrc"/>
</resources>
<connections/>
</ui>

View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'graph_editor_widget.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QGraphicsView, QSizePolicy,
QToolBar, QVBoxLayout, QWidget)
import resources_rc
class Ui_graphEditorWidget(object):
def setupUi(self, graphEditorWidget):
if not graphEditorWidget.objectName():
graphEditorWidget.setObjectName(u"graphEditorWidget")
graphEditorWidget.resize(1079, 730)
self.actionZoomToFit = QAction(graphEditorWidget)
self.actionZoomToFit.setObjectName(u"actionZoomToFit")
icon = QIcon()
icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomToFit.setIcon(icon)
self.actionMouseMode = QAction(graphEditorWidget)
self.actionMouseMode.setObjectName(u"actionMouseMode")
self.actionMouseMode.setCheckable(True)
icon1 = QIcon()
icon1.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionMouseMode.setIcon(icon1)
self.actionConnectionMode = QAction(graphEditorWidget)
self.actionConnectionMode.setObjectName(u"actionConnectionMode")
self.actionConnectionMode.setCheckable(True)
icon2 = QIcon()
icon2.addFile(u":/icons/icons/network-connect.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionConnectionMode.setIcon(icon2)
self.verticalLayout = QVBoxLayout(graphEditorWidget)
self.verticalLayout.setSpacing(0)
self.verticalLayout.setObjectName(u"verticalLayout")
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.graphToolBar = QToolBar(graphEditorWidget)
self.graphToolBar.setObjectName(u"graphToolBar")
self.graphToolBar.setMovable(False)
self.graphToolBar.setFloatable(False)
self.verticalLayout.addWidget(self.graphToolBar)
self.graphicsView = QGraphicsView(graphEditorWidget)
self.graphicsView.setObjectName(u"graphicsView")
self.graphicsView.setFrameShape(QFrame.Shape.NoFrame)
self.graphicsView.setRenderHints(QPainter.RenderHint.Antialiasing)
self.graphicsView.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.verticalLayout.addWidget(self.graphicsView)
self.graphToolBar.addAction(self.actionZoomToFit)
self.graphToolBar.addAction(self.actionMouseMode)
self.graphToolBar.addAction(self.actionConnectionMode)
self.retranslateUi(graphEditorWidget)
QMetaObject.connectSlotsByName(graphEditorWidget)
# setupUi
def retranslateUi(self, graphEditorWidget):
graphEditorWidget.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph Editor", None))
self.actionZoomToFit.setText(QCoreApplication.translate("graphEditorWidget", u"Zoom to Fit", None))
#if QT_CONFIG(tooltip)
self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
#endif // QT_CONFIG(tooltip)
self.actionMouseMode.setText(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
#if QT_CONFIG(tooltip)
self.actionMouseMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
#endif // QT_CONFIG(tooltip)
self.actionConnectionMode.setText(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
#if QT_CONFIG(tooltip)
self.actionConnectionMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
#endif // QT_CONFIG(tooltip)
self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
# retranslateUi

View File

@@ -30,7 +30,7 @@
<x>0</x>
<y>0</y>
<width>800</width>
<height>22</height>
<height>19</height>
</rect>
</property>
<widget class="QMenu" name="menuEdit">
@@ -80,6 +80,7 @@
<bool>false</bool>
</attribute>
<addaction name="actionAdd_Rectangle"/>
<addaction name="actionAdd_Circle"/>
<addaction name="actionAdd_Text"/>
<addaction name="actionAdd_Line"/>
</widget>
@@ -236,6 +237,21 @@
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAdd_Circle">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-circle.png</normaloff>:/icons/icons/draw-circle.png</iconset>
</property>
<property name="text">
<string>Add Ellipse</string>
</property>
<property name="toolTip">
<string>Add a circle</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>

View File

@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'icon_editor_window.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 (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QGraphicsView, QMainWindow, QMenu,
QMenuBar, QSizePolicy, QStatusBar, QToolBar,
QVBoxLayout, QWidget)
import resources_rc
class Ui_iconEditor(object):
def setupUi(self, iconEditor):
if not iconEditor.objectName():
iconEditor.setObjectName(u"iconEditor")
iconEditor.resize(800, 600)
icon = QIcon()
icon.addFile(u":/icons/icons/draw-path.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
iconEditor.setWindowIcon(icon)
self.actionUndo = QAction(iconEditor)
self.actionUndo.setObjectName(u"actionUndo")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon1)
self.actionUndo.setMenuRole(QAction.MenuRole.NoRole)
self.actionRedo = QAction(iconEditor)
self.actionRedo.setObjectName(u"actionRedo")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon2)
self.actionRedo.setMenuRole(QAction.MenuRole.NoRole)
self.actionSave = QAction(iconEditor)
self.actionSave.setObjectName(u"actionSave")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon3)
self.actionSave.setMenuRole(QAction.MenuRole.NoRole)
self.actionCancel = QAction(iconEditor)
self.actionCancel.setObjectName(u"actionCancel")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/dialog-close.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCancel.setIcon(icon4)
self.actionCancel.setMenuRole(QAction.MenuRole.NoRole)
self.actionAdd_Rectangle = QAction(iconEditor)
self.actionAdd_Rectangle.setObjectName(u"actionAdd_Rectangle")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/draw-rectangle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionAdd_Rectangle.setIcon(icon5)
self.actionAdd_Rectangle.setMenuRole(QAction.MenuRole.NoRole)
self.actionAdd_Text = QAction(iconEditor)
self.actionAdd_Text.setObjectName(u"actionAdd_Text")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/draw-text.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionAdd_Text.setIcon(icon6)
self.actionAdd_Text.setMenuRole(QAction.MenuRole.NoRole)
self.actionSave_to_File = QAction(iconEditor)
self.actionSave_to_File.setObjectName(u"actionSave_to_File")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave_to_File.setIcon(icon7)
self.actionSave_to_File.setMenuRole(QAction.MenuRole.NoRole)
self.actionOpen_from_File = QAction(iconEditor)
self.actionOpen_from_File.setObjectName(u"actionOpen_from_File")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen_from_File.setIcon(icon8)
self.actionOpen_from_File.setMenuRole(QAction.MenuRole.NoRole)
self.actionAdd_Line = QAction(iconEditor)
self.actionAdd_Line.setObjectName(u"actionAdd_Line")
self.actionAdd_Line.setIcon(icon)
self.actionAdd_Line.setMenuRole(QAction.MenuRole.NoRole)
self.actionAdd_Circle = QAction(iconEditor)
self.actionAdd_Circle.setObjectName(u"actionAdd_Circle")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/draw-circle.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionAdd_Circle.setIcon(icon9)
self.actionAdd_Circle.setMenuRole(QAction.MenuRole.NoRole)
self.centralwidget = QWidget(iconEditor)
self.centralwidget.setObjectName(u"centralwidget")
self.verticalLayout = QVBoxLayout(self.centralwidget)
self.verticalLayout.setObjectName(u"verticalLayout")
self.graphicsView = QGraphicsView(self.centralwidget)
self.graphicsView.setObjectName(u"graphicsView")
self.verticalLayout.addWidget(self.graphicsView)
iconEditor.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(iconEditor)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 800, 19))
self.menuEdit = QMenu(self.menubar)
self.menuEdit.setObjectName(u"menuEdit")
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
iconEditor.setMenuBar(self.menubar)
self.statusbar = QStatusBar(iconEditor)
self.statusbar.setObjectName(u"statusbar")
iconEditor.setStatusBar(self.statusbar)
self.actionToolbar = QToolBar(iconEditor)
self.actionToolbar.setObjectName(u"actionToolbar")
iconEditor.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.actionToolbar)
self.iconToolbar = QToolBar(iconEditor)
self.iconToolbar.setObjectName(u"iconToolbar")
iconEditor.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.iconToolbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction())
self.menuEdit.addAction(self.actionUndo)
self.menuEdit.addAction(self.actionRedo)
self.menuFile.addAction(self.actionOpen_from_File)
self.menuFile.addAction(self.actionSave_to_File)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSave)
self.menuFile.addAction(self.actionCancel)
self.actionToolbar.addAction(self.actionUndo)
self.actionToolbar.addAction(self.actionRedo)
self.actionToolbar.addAction(self.actionSave)
self.actionToolbar.addAction(self.actionCancel)
self.iconToolbar.addAction(self.actionAdd_Rectangle)
self.iconToolbar.addAction(self.actionAdd_Circle)
self.iconToolbar.addAction(self.actionAdd_Text)
self.iconToolbar.addAction(self.actionAdd_Line)
self.retranslateUi(iconEditor)
QMetaObject.connectSlotsByName(iconEditor)
# setupUi
def retranslateUi(self, iconEditor):
iconEditor.setWindowTitle(QCoreApplication.translate("iconEditor", u"MainWindow", None))
self.actionUndo.setText(QCoreApplication.translate("iconEditor", u"Undo", None))
#if QT_CONFIG(tooltip)
self.actionUndo.setToolTip(QCoreApplication.translate("iconEditor", u"Undo", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionUndo.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Z", None))
#endif // QT_CONFIG(shortcut)
self.actionRedo.setText(QCoreApplication.translate("iconEditor", u"Redo", None))
#if QT_CONFIG(tooltip)
self.actionRedo.setToolTip(QCoreApplication.translate("iconEditor", u"Redo", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionRedo.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Y", None))
#endif // QT_CONFIG(shortcut)
self.actionSave.setText(QCoreApplication.translate("iconEditor", u"Save", None))
#if QT_CONFIG(tooltip)
self.actionSave.setToolTip(QCoreApplication.translate("iconEditor", u"Save icon", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionSave.setShortcut(QCoreApplication.translate("iconEditor", u"Return", None))
#endif // QT_CONFIG(shortcut)
self.actionCancel.setText(QCoreApplication.translate("iconEditor", u"Cancel", None))
#if QT_CONFIG(tooltip)
self.actionCancel.setToolTip(QCoreApplication.translate("iconEditor", u"Cancel icon editing", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionCancel.setShortcut(QCoreApplication.translate("iconEditor", u"Shift+Esc", None))
#endif // QT_CONFIG(shortcut)
self.actionAdd_Rectangle.setText(QCoreApplication.translate("iconEditor", u"Add Rectangle", None))
#if QT_CONFIG(tooltip)
self.actionAdd_Rectangle.setToolTip(QCoreApplication.translate("iconEditor", u"Add a rectangle", None))
#endif // QT_CONFIG(tooltip)
self.actionAdd_Text.setText(QCoreApplication.translate("iconEditor", u"Add Text", None))
#if QT_CONFIG(tooltip)
self.actionAdd_Text.setToolTip(QCoreApplication.translate("iconEditor", u"Add a text field", None))
#endif // QT_CONFIG(tooltip)
self.actionSave_to_File.setText(QCoreApplication.translate("iconEditor", u"Save to File", None))
#if QT_CONFIG(tooltip)
self.actionSave_to_File.setToolTip(QCoreApplication.translate("iconEditor", u"Save icon to a file", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionSave_to_File.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen_from_File.setText(QCoreApplication.translate("iconEditor", u"Open from File", None))
#if QT_CONFIG(tooltip)
self.actionOpen_from_File.setToolTip(QCoreApplication.translate("iconEditor", u"Open icon from File", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionOpen_from_File.setShortcut(QCoreApplication.translate("iconEditor", u"Ctrl+Shift+O", None))
#endif // QT_CONFIG(shortcut)
self.actionAdd_Line.setText(QCoreApplication.translate("iconEditor", u"Add Line", None))
#if QT_CONFIG(tooltip)
self.actionAdd_Line.setToolTip(QCoreApplication.translate("iconEditor", u"Add a line", None))
#endif // QT_CONFIG(tooltip)
self.actionAdd_Circle.setText(QCoreApplication.translate("iconEditor", u"Add Ellipse", None))
#if QT_CONFIG(tooltip)
self.actionAdd_Circle.setToolTip(QCoreApplication.translate("iconEditor", u"Add a circle", None))
#endif // QT_CONFIG(tooltip)
self.menuEdit.setTitle(QCoreApplication.translate("iconEditor", u"Edit", None))
self.menuFile.setTitle(QCoreApplication.translate("iconEditor", u"File", None))
self.actionToolbar.setWindowTitle(QCoreApplication.translate("iconEditor", u"toolBar", None))
self.iconToolbar.setWindowTitle(QCoreApplication.translate("iconEditor", u"toolBar", None))
# retranslateUi

View File

@@ -30,7 +30,7 @@
<x>0</x>
<y>0</y>
<width>940</width>
<height>22</height>
<height>19</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
@@ -59,6 +59,7 @@
<addaction name="actionDelete"/>
<addaction name="separator"/>
<addaction name="actionSettings"/>
<addaction name="actionReload_Libraries"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
@@ -127,7 +128,7 @@
<property name="minimumSize">
<size>
<width>150</width>
<height>533</height>
<height>200</height>
</size>
</property>
<property name="windowTitle">
@@ -180,6 +181,27 @@
<addaction name="actionCompile_Model"/>
<addaction name="actionOpen_Simulation_Window"/>
</widget>
<widget class="QDockWidget" name="libraryWidget">
<property name="minimumSize">
<size>
<width>150</width>
<height>200</height>
</size>
</property>
<property name="windowTitle">
<string>Libraries</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_4">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QTreeView" name="libraryTree"/>
</item>
</layout>
</widget>
</widget>
<action name="actionOpen_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
@@ -330,6 +352,17 @@
<string>Settings</string>
</property>
</action>
<action name="actionReload_Libraries">
<property name="text">
<string>Reload Libraries</string>
</property>
<property name="toolTip">
<string>Reload configured libraries</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionDelete">
<property name="icon">
<iconset resource="../../resources/resources.qrc">

View File

@@ -0,0 +1,370 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'main_window.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 (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QHeaderView, QListView,
QMainWindow, QMenu, QMenuBar, QSizePolicy,
QStatusBar, QTabWidget, QToolBar, QTreeView,
QVBoxLayout, QWidget)
import resources_rc
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(940, 729)
icon = QIcon()
icon.addFile(u":/icons/icons/office-chart-line.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
MainWindow.setWindowIcon(icon)
MainWindow.setDocumentMode(False)
MainWindow.setTabShape(QTabWidget.TabShape.Triangular)
self.actionOpen_File = QAction(MainWindow)
self.actionOpen_File.setObjectName(u"actionOpen_File")
icon1 = QIcon()
icon1.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen_File.setIcon(icon1)
self.actionOpen_File.setMenuRole(QAction.MenuRole.NoRole)
self.actionNew_File = QAction(MainWindow)
self.actionNew_File.setObjectName(u"actionNew_File")
icon2 = QIcon()
icon2.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew_File.setIcon(icon2)
self.actionNew_File.setMenuRole(QAction.MenuRole.NoRole)
self.actionSave_File = QAction(MainWindow)
self.actionSave_File.setObjectName(u"actionSave_File")
icon3 = QIcon()
icon3.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave_File.setIcon(icon3)
self.actionSave_File.setMenuRole(QAction.MenuRole.NoRole)
self.actionSave_File_As = QAction(MainWindow)
self.actionSave_File_As.setObjectName(u"actionSave_File_As")
icon4 = QIcon()
icon4.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave_File_As.setIcon(icon4)
self.actionSave_File_As.setMenuRole(QAction.MenuRole.NoRole)
self.actionClose = QAction(MainWindow)
self.actionClose.setObjectName(u"actionClose")
self.actionClose.setMenuRole(QAction.MenuRole.NoRole)
self.actionAbout_QT = QAction(MainWindow)
self.actionAbout_QT.setObjectName(u"actionAbout_QT")
self.actionAbout_QT.setMenuRole(QAction.MenuRole.AboutQtRole)
self.actionUndo = QAction(MainWindow)
self.actionUndo.setObjectName(u"actionUndo")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/edit-undo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionUndo.setIcon(icon5)
self.actionUndo.setMenuRole(QAction.MenuRole.NoRole)
self.actionRedo = QAction(MainWindow)
self.actionRedo.setObjectName(u"actionRedo")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/edit-redo.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRedo.setIcon(icon6)
self.actionRedo.setMenuRole(QAction.MenuRole.NoRole)
self.actionReset_Layout = QAction(MainWindow)
self.actionReset_Layout.setObjectName(u"actionReset_Layout")
self.actionReset_Layout.setMenuRole(QAction.MenuRole.NoRole)
self.actionPanels = QAction(MainWindow)
self.actionPanels.setObjectName(u"actionPanels")
self.actionToolbars = QAction(MainWindow)
self.actionToolbars.setObjectName(u"actionToolbars")
self.actionSettings = QAction(MainWindow)
self.actionSettings.setObjectName(u"actionSettings")
self.actionReload_Libraries = QAction(MainWindow)
self.actionReload_Libraries.setObjectName(u"actionReload_Libraries")
self.actionReload_Libraries.setMenuRole(QAction.MenuRole.NoRole)
self.actionDelete = QAction(MainWindow)
self.actionDelete.setObjectName(u"actionDelete")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/edit-delete.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionDelete.setIcon(icon7)
self.actionDelete.setMenuRole(QAction.MenuRole.NoRole)
self.actionEscape = QAction(MainWindow)
self.actionEscape.setObjectName(u"actionEscape")
self.actionEscape.setMenuRole(QAction.MenuRole.NoRole)
self.actionCopy = QAction(MainWindow)
self.actionCopy.setObjectName(u"actionCopy")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/edit-copy.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCopy.setIcon(icon8)
self.actionCopy.setMenuRole(QAction.MenuRole.NoRole)
self.actionPaste = QAction(MainWindow)
self.actionPaste.setObjectName(u"actionPaste")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/edit-paste.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionPaste.setIcon(icon9)
self.actionPaste.setMenuRole(QAction.MenuRole.NoRole)
self.actionCut = QAction(MainWindow)
self.actionCut.setObjectName(u"actionCut")
icon10 = QIcon()
icon10.addFile(u":/icons/icons/edit-cut.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCut.setIcon(icon10)
self.actionCut.setMenuRole(QAction.MenuRole.NoRole)
self.actionSimulation_Settings = QAction(MainWindow)
self.actionSimulation_Settings.setObjectName(u"actionSimulation_Settings")
icon11 = QIcon()
icon11.addFile(u":/icons/icons/configure.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSimulation_Settings.setIcon(icon11)
self.actionSimulation_Settings.setMenuRole(QAction.MenuRole.NoRole)
self.actionEdit_Parameters = QAction(MainWindow)
self.actionEdit_Parameters.setObjectName(u"actionEdit_Parameters")
icon12 = QIcon()
icon12.addFile(u":/icons/icons/view-form-table.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionEdit_Parameters.setIcon(icon12)
self.actionEdit_Parameters.setMenuRole(QAction.MenuRole.NoRole)
self.actionCompile_Model = QAction(MainWindow)
self.actionCompile_Model.setObjectName(u"actionCompile_Model")
icon13 = QIcon()
icon13.addFile(u":/icons/icons/run-build.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCompile_Model.setIcon(icon13)
self.actionCompile_Model.setMenuRole(QAction.MenuRole.NoRole)
self.actionOpen_Simulation_Window = QAction(MainWindow)
self.actionOpen_Simulation_Window.setObjectName(u"actionOpen_Simulation_Window")
self.actionOpen_Simulation_Window.setIcon(icon)
self.actionOpen_Simulation_Window.setMenuRole(QAction.MenuRole.NoRole)
self.actionAbout = QAction(MainWindow)
self.actionAbout.setObjectName(u"actionAbout")
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 940, 19))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuEdit = QMenu(self.menubar)
self.menuEdit.setObjectName(u"menuEdit")
self.menuView = QMenu(self.menubar)
self.menuView.setObjectName(u"menuView")
self.menuHelp = QMenu(self.menubar)
self.menuHelp.setObjectName(u"menuHelp")
self.menuSimulation = QMenu(self.menubar)
self.menuSimulation.setObjectName(u"menuSimulation")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName(u"statusbar")
MainWindow.setStatusBar(self.statusbar)
self.fileToolBar = QToolBar(MainWindow)
self.fileToolBar.setObjectName(u"fileToolBar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.fileToolBar)
self.undoToolBar = QToolBar(MainWindow)
self.undoToolBar.setObjectName(u"undoToolBar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.undoToolBar)
self.documentTreeWidget = QDockWidget(MainWindow)
self.documentTreeWidget.setObjectName(u"documentTreeWidget")
self.documentTreeWidget.setMinimumSize(QSize(150, 200))
self.dockWidgetContents = QWidget()
self.dockWidgetContents.setObjectName(u"dockWidgetContents")
self.verticalLayout = QVBoxLayout(self.dockWidgetContents)
self.verticalLayout.setObjectName(u"verticalLayout")
self.documentTree = QTreeView(self.dockWidgetContents)
self.documentTree.setObjectName(u"documentTree")
self.verticalLayout.addWidget(self.documentTree)
self.documentTreeWidget.setWidget(self.dockWidgetContents)
MainWindow.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.documentTreeWidget)
self.logWidget = QDockWidget(MainWindow)
self.logWidget.setObjectName(u"logWidget")
self.logWidget.setMinimumSize(QSize(150, 107))
self.dockWidgetContents_5 = QWidget()
self.dockWidgetContents_5.setObjectName(u"dockWidgetContents_5")
self.verticalLayout_2 = QVBoxLayout(self.dockWidgetContents_5)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.listView = QListView(self.dockWidgetContents_5)
self.listView.setObjectName(u"listView")
self.verticalLayout_2.addWidget(self.listView)
self.logWidget.setWidget(self.dockWidgetContents_5)
MainWindow.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.logWidget)
self.simToolBar = QToolBar(MainWindow)
self.simToolBar.setObjectName(u"simToolBar")
MainWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.simToolBar)
self.libraryWidget = QDockWidget(MainWindow)
self.libraryWidget.setObjectName(u"libraryWidget")
self.libraryWidget.setMinimumSize(QSize(150, 200))
self.dockWidgetContents_4 = QWidget()
self.dockWidgetContents_4.setObjectName(u"dockWidgetContents_4")
self.verticalLayout_4 = QVBoxLayout(self.dockWidgetContents_4)
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
self.libraryTree = QTreeView(self.dockWidgetContents_4)
self.libraryTree.setObjectName(u"libraryTree")
self.verticalLayout_4.addWidget(self.libraryTree)
self.libraryWidget.setWidget(self.dockWidgetContents_4)
MainWindow.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.libraryWidget)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuEdit.menuAction())
self.menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuSimulation.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.menuFile.addAction(self.actionNew_File)
self.menuFile.addAction(self.actionOpen_File)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSave_File)
self.menuFile.addAction(self.actionSave_File_As)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionClose)
self.menuEdit.addAction(self.actionUndo)
self.menuEdit.addAction(self.actionRedo)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionCopy)
self.menuEdit.addAction(self.actionCut)
self.menuEdit.addAction(self.actionPaste)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionDelete)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.actionSettings)
self.menuEdit.addAction(self.actionReload_Libraries)
self.menuView.addAction(self.actionReset_Layout)
self.menuView.addAction(self.actionPanels)
self.menuView.addAction(self.actionToolbars)
self.menuHelp.addAction(self.actionAbout)
self.menuHelp.addAction(self.actionAbout_QT)
self.menuSimulation.addAction(self.actionSimulation_Settings)
self.menuSimulation.addAction(self.actionEdit_Parameters)
self.menuSimulation.addSeparator()
self.menuSimulation.addAction(self.actionCompile_Model)
self.menuSimulation.addAction(self.actionOpen_Simulation_Window)
self.fileToolBar.addAction(self.actionNew_File)
self.fileToolBar.addAction(self.actionOpen_File)
self.fileToolBar.addAction(self.actionSave_File)
self.fileToolBar.addAction(self.actionSave_File_As)
self.undoToolBar.addAction(self.actionUndo)
self.undoToolBar.addAction(self.actionRedo)
self.undoToolBar.addAction(self.actionCopy)
self.undoToolBar.addAction(self.actionCut)
self.undoToolBar.addAction(self.actionPaste)
self.simToolBar.addAction(self.actionSimulation_Settings)
self.simToolBar.addAction(self.actionEdit_Parameters)
self.simToolBar.addAction(self.actionCompile_Model)
self.simToolBar.addAction(self.actionOpen_Simulation_Window)
self.retranslateUi(MainWindow)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None))
self.actionOpen_File.setText(QCoreApplication.translate("MainWindow", u"Open File", None))
#if QT_CONFIG(tooltip)
self.actionOpen_File.setToolTip(QCoreApplication.translate("MainWindow", u"Open a file", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionOpen_File.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionNew_File.setText(QCoreApplication.translate("MainWindow", u"New File", None))
#if QT_CONFIG(tooltip)
self.actionNew_File.setToolTip(QCoreApplication.translate("MainWindow", u"Create a new file", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionNew_File.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+N", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_File.setText(QCoreApplication.translate("MainWindow", u"Save File", None))
#if QT_CONFIG(tooltip)
self.actionSave_File.setToolTip(QCoreApplication.translate("MainWindow", u"Save a file to disk", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionSave_File.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+S", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_File_As.setText(QCoreApplication.translate("MainWindow", u"Save File As...", None))
#if QT_CONFIG(tooltip)
self.actionSave_File_As.setToolTip(QCoreApplication.translate("MainWindow", u"Save file to disk as another file", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionSave_File_As.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionClose.setText(QCoreApplication.translate("MainWindow", u"Close", None))
#if QT_CONFIG(tooltip)
self.actionClose.setToolTip(QCoreApplication.translate("MainWindow", u"Close application", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionClose.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
self.actionAbout_QT.setText(QCoreApplication.translate("MainWindow", u"About QT", None))
self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"Undo", None))
#if QT_CONFIG(shortcut)
self.actionUndo.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Z", None))
#endif // QT_CONFIG(shortcut)
self.actionRedo.setText(QCoreApplication.translate("MainWindow", u"Redo", None))
#if QT_CONFIG(shortcut)
self.actionRedo.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Y", None))
#endif // QT_CONFIG(shortcut)
self.actionReset_Layout.setText(QCoreApplication.translate("MainWindow", u"Reset Layout", None))
#if QT_CONFIG(tooltip)
self.actionReset_Layout.setToolTip(QCoreApplication.translate("MainWindow", u"Reset window layout to default", None))
#endif // QT_CONFIG(tooltip)
self.actionPanels.setText(QCoreApplication.translate("MainWindow", u"Panels", None))
self.actionToolbars.setText(QCoreApplication.translate("MainWindow", u"Toolbars", None))
self.actionSettings.setText(QCoreApplication.translate("MainWindow", u"Settings", None))
self.actionReload_Libraries.setText(QCoreApplication.translate("MainWindow", u"Reload Libraries", None))
#if QT_CONFIG(tooltip)
self.actionReload_Libraries.setToolTip(QCoreApplication.translate("MainWindow", u"Reload configured libraries", None))
#endif // QT_CONFIG(tooltip)
self.actionDelete.setText(QCoreApplication.translate("MainWindow", u"Delete", None))
#if QT_CONFIG(tooltip)
self.actionDelete.setToolTip(QCoreApplication.translate("MainWindow", u"Delete selected", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionDelete.setShortcut(QCoreApplication.translate("MainWindow", u"Del", None))
#endif // QT_CONFIG(shortcut)
self.actionEscape.setText(QCoreApplication.translate("MainWindow", u"Escape", None))
#if QT_CONFIG(shortcut)
self.actionEscape.setShortcut(QCoreApplication.translate("MainWindow", u"Esc", None))
#endif // QT_CONFIG(shortcut)
self.actionCopy.setText(QCoreApplication.translate("MainWindow", u"Copy", None))
#if QT_CONFIG(tooltip)
self.actionCopy.setToolTip(QCoreApplication.translate("MainWindow", u"Copy selected", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionCopy.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+C", None))
#endif // QT_CONFIG(shortcut)
self.actionPaste.setText(QCoreApplication.translate("MainWindow", u"Paste", None))
#if QT_CONFIG(tooltip)
self.actionPaste.setToolTip(QCoreApplication.translate("MainWindow", u"Paste selected", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionPaste.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+V", None))
#endif // QT_CONFIG(shortcut)
self.actionCut.setText(QCoreApplication.translate("MainWindow", u"Cut", None))
#if QT_CONFIG(tooltip)
self.actionCut.setToolTip(QCoreApplication.translate("MainWindow", u"Cut selected", None))
#endif // QT_CONFIG(tooltip)
#if QT_CONFIG(shortcut)
self.actionCut.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+X", None))
#endif // QT_CONFIG(shortcut)
self.actionSimulation_Settings.setText(QCoreApplication.translate("MainWindow", u"Simulation Settings", None))
self.actionEdit_Parameters.setText(QCoreApplication.translate("MainWindow", u"Edit Parameters", None))
self.actionCompile_Model.setText(QCoreApplication.translate("MainWindow", u"Compile Model", None))
self.actionOpen_Simulation_Window.setText(QCoreApplication.translate("MainWindow", u"Open Simulation Window", None))
self.actionAbout.setText(QCoreApplication.translate("MainWindow", u"About", 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))
self.menuHelp.setTitle(QCoreApplication.translate("MainWindow", u"Help", None))
self.menuSimulation.setTitle(QCoreApplication.translate("MainWindow", u"Simulation", None))
self.fileToolBar.setWindowTitle(QCoreApplication.translate("MainWindow", u"toolBar", None))
self.undoToolBar.setWindowTitle(QCoreApplication.translate("MainWindow", u"toolBar", None))
self.documentTreeWidget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Document Tree", None))
self.logWidget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Log", None))
self.simToolBar.setWindowTitle(QCoreApplication.translate("MainWindow", u"toolBar", None))
self.libraryWidget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Libraries", None))
# retranslateUi

View File

@@ -148,6 +148,20 @@
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="connectionAnnotationLabel">
<property name="text">
<string>Connection annotation:</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QLineEdit" name="connectionAnnotationEdit">
<property name="placeholderText">
<string>For example, + or -</string>
</property>
</widget>
</item>
</layout>
</item>
<item>

View File

@@ -0,0 +1,268 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'port_editor_widget.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFormLayout,
QFrame, QHBoxLayout, QLabel, QLineEdit,
QListView, QPlainTextEdit, QPushButton, QRadioButton,
QSizePolicy, QSpacerItem, QSpinBox, QVBoxLayout,
QWidget)
class Ui_PortEditor(object):
def setupUi(self, PortEditor):
if not PortEditor.objectName():
PortEditor.setObjectName(u"PortEditor")
PortEditor.resize(541, 420)
self.horizontalLayout_2 = QHBoxLayout(PortEditor)
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
self.leftColumn = QVBoxLayout()
self.leftColumn.setObjectName(u"leftColumn")
self.portList = QListView(PortEditor)
self.portList.setObjectName(u"portList")
self.leftColumn.addWidget(self.portList)
self.buttonRow = QHBoxLayout()
self.buttonRow.setObjectName(u"buttonRow")
self.addPort = QPushButton(PortEditor)
self.addPort.setObjectName(u"addPort")
self.buttonRow.addWidget(self.addPort)
self.removePort = QPushButton(PortEditor)
self.removePort.setObjectName(u"removePort")
self.buttonRow.addWidget(self.removePort)
self.leftColumn.addLayout(self.buttonRow)
self.horizontalLayout_2.addLayout(self.leftColumn)
self.rightColumn = QVBoxLayout()
self.rightColumn.setObjectName(u"rightColumn")
self.basicForm = QFormLayout()
self.basicForm.setObjectName(u"basicForm")
self.nameLabel = QLabel(PortEditor)
self.nameLabel.setObjectName(u"nameLabel")
self.basicForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel)
self.nameEdit = QLineEdit(PortEditor)
self.nameEdit.setObjectName(u"nameEdit")
self.basicForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit)
self.typeLabel = QLabel(PortEditor)
self.typeLabel.setObjectName(u"typeLabel")
self.basicForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel)
self.typeRow = QHBoxLayout()
self.typeRow.setObjectName(u"typeRow")
self.typeSignal = QRadioButton(PortEditor)
self.typeSignal.setObjectName(u"typeSignal")
self.typeRow.addWidget(self.typeSignal)
self.typeBond = QRadioButton(PortEditor)
self.typeBond.setObjectName(u"typeBond")
self.typeRow.addWidget(self.typeBond)
self.basicForm.setLayout(1, QFormLayout.ItemRole.FieldRole, self.typeRow)
self.orientationLabel = QLabel(PortEditor)
self.orientationLabel.setObjectName(u"orientationLabel")
self.basicForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.orientationLabel)
self.orientationRow = QHBoxLayout()
self.orientationRow.setObjectName(u"orientationRow")
self.inputOrientation = QRadioButton(PortEditor)
self.inputOrientation.setObjectName(u"inputOrientation")
self.orientationRow.addWidget(self.inputOrientation)
self.outputOrientation = QRadioButton(PortEditor)
self.outputOrientation.setObjectName(u"outputOrientation")
self.orientationRow.addWidget(self.outputOrientation)
self.basicForm.setLayout(2, QFormLayout.ItemRole.FieldRole, self.orientationRow)
self.sizeLabel = QLabel(PortEditor)
self.sizeLabel.setObjectName(u"sizeLabel")
self.basicForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.sizeLabel)
self.sizeRow = QHBoxLayout()
self.sizeRow.setObjectName(u"sizeRow")
self.widthSize = QSpinBox(PortEditor)
self.widthSize.setObjectName(u"widthSize")
self.widthSize.setMinimum(1)
self.sizeRow.addWidget(self.widthSize)
self.heightSize = QSpinBox(PortEditor)
self.heightSize.setObjectName(u"heightSize")
self.heightSize.setMinimum(1)
self.sizeRow.addWidget(self.heightSize)
self.basicForm.setLayout(3, QFormLayout.ItemRole.FieldRole, self.sizeRow)
self.domainLabel = QLabel(PortEditor)
self.domainLabel.setObjectName(u"domainLabel")
self.basicForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.domainLabel)
self.multiplicityCheckBox = QCheckBox(PortEditor)
self.multiplicityCheckBox.setObjectName(u"multiplicityCheckBox")
self.basicForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.multiplicityCheckBox)
self.connectionAnnotationLabel = QLabel(PortEditor)
self.connectionAnnotationLabel.setObjectName(u"connectionAnnotationLabel")
self.basicForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.connectionAnnotationLabel)
self.connectionAnnotationEdit = QLineEdit(PortEditor)
self.connectionAnnotationEdit.setObjectName(u"connectionAnnotationEdit")
self.basicForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.connectionAnnotationEdit)
self.rightColumn.addLayout(self.basicForm)
self.line = QFrame(PortEditor)
self.line.setObjectName(u"line")
self.line.setFrameShape(QFrame.Shape.HLine)
self.line.setFrameShadow(QFrame.Shadow.Sunken)
self.rightColumn.addWidget(self.line)
self.signalOptions = QFormLayout()
self.signalOptions.setObjectName(u"signalOptions")
self.signalTypeLabel = QLabel(PortEditor)
self.signalTypeLabel.setObjectName(u"signalTypeLabel")
self.signalOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.signalTypeLabel)
self.signalTypeComboBox = QComboBox(PortEditor)
self.signalTypeComboBox.setObjectName(u"signalTypeComboBox")
self.signalOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.signalTypeComboBox)
self.rightColumn.addLayout(self.signalOptions)
self.bondOptions = QFormLayout()
self.bondOptions.setObjectName(u"bondOptions")
self.domainLabel_2 = QLabel(PortEditor)
self.domainLabel_2.setObjectName(u"domainLabel_2")
self.bondOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.domainLabel_2)
self.domainComboBox = QComboBox(PortEditor)
self.domainComboBox.setObjectName(u"domainComboBox")
self.bondOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.domainComboBox)
self.causalityLabel = QLabel(PortEditor)
self.causalityLabel.setObjectName(u"causalityLabel")
self.bondOptions.setWidget(1, QFormLayout.ItemRole.LabelRole, self.causalityLabel)
self.causalityComboBox = QComboBox(PortEditor)
self.causalityComboBox.setObjectName(u"causalityComboBox")
self.bondOptions.setWidget(1, QFormLayout.ItemRole.FieldRole, self.causalityComboBox)
self.rightColumn.addLayout(self.bondOptions)
self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.rightColumn.addItem(self.verticalSpacer)
self.line_2 = QFrame(PortEditor)
self.line_2.setObjectName(u"line_2")
self.line_2.setFrameShape(QFrame.Shape.HLine)
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
self.rightColumn.addWidget(self.line_2)
self.descriptionForm = QFormLayout()
self.descriptionForm.setObjectName(u"descriptionForm")
self.descriptionForm.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
self.descriptionLabel = QLabel(PortEditor)
self.descriptionLabel.setObjectName(u"descriptionLabel")
self.descriptionForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.descriptionLabel)
self.descriptionEdit = QPlainTextEdit(PortEditor)
self.descriptionEdit.setObjectName(u"descriptionEdit")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.descriptionEdit.sizePolicy().hasHeightForWidth())
self.descriptionEdit.setSizePolicy(sizePolicy)
self.descriptionEdit.setMinimumSize(QSize(0, 20))
self.descriptionEdit.setMaximumSize(QSize(16777215, 60))
self.descriptionForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.descriptionEdit)
self.rightColumn.addLayout(self.descriptionForm)
self.horizontalLayout_2.addLayout(self.rightColumn)
self.retranslateUi(PortEditor)
QMetaObject.connectSlotsByName(PortEditor)
# setupUi
def retranslateUi(self, PortEditor):
PortEditor.setWindowTitle(QCoreApplication.translate("PortEditor", u"Form", None))
self.addPort.setText(QCoreApplication.translate("PortEditor", u"Add Port", None))
self.removePort.setText(QCoreApplication.translate("PortEditor", u"Remove Port", None))
self.nameLabel.setText(QCoreApplication.translate("PortEditor", u"Name:", None))
self.typeLabel.setText(QCoreApplication.translate("PortEditor", u"Type:", None))
self.typeSignal.setText(QCoreApplication.translate("PortEditor", u"Signal", None))
self.typeBond.setText(QCoreApplication.translate("PortEditor", u"Power Bond", None))
self.orientationLabel.setText(QCoreApplication.translate("PortEditor", u"Orientation:", None))
self.inputOrientation.setText(QCoreApplication.translate("PortEditor", u"Input", None))
self.outputOrientation.setText(QCoreApplication.translate("PortEditor", u"Output", None))
self.sizeLabel.setText(QCoreApplication.translate("PortEditor", u"Size", None))
self.widthSize.setSuffix(QCoreApplication.translate("PortEditor", u" rows", None))
self.heightSize.setSuffix(QCoreApplication.translate("PortEditor", u" columns", None))
self.domainLabel.setText("")
self.multiplicityCheckBox.setText(QCoreApplication.translate("PortEditor", u"Allow multiple connections", None))
self.connectionAnnotationLabel.setText(QCoreApplication.translate("PortEditor", u"Connection annotation:", None))
self.connectionAnnotationEdit.setPlaceholderText(QCoreApplication.translate("PortEditor", u"For example, + or -", None))
self.signalTypeLabel.setText(QCoreApplication.translate("PortEditor", u"Signal Type:", None))
self.domainLabel_2.setText(QCoreApplication.translate("PortEditor", u"Domain:", None))
self.causalityLabel.setText(QCoreApplication.translate("PortEditor", u"Causality:", None))
self.descriptionLabel.setText(QCoreApplication.translate("PortEditor", u"Description:", None))
# retranslateUi

View File

@@ -17,7 +17,7 @@
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
<number>1</number>
</property>
<widget class="QWidget" name="General">
<attribute name="title">
@@ -55,7 +55,27 @@
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="labelSnapToGridSize">
<property name="text">
<string>Snap-to-grid size:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="snapToGridSize">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>256</number>
</property>
<property name="value">
<number>4</number>
</property>
</widget>
</item>
<item row="2" column="1">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
@@ -70,6 +90,34 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="Libraries">
<attribute name="title">
<string>Libraries</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QListView" name="listView"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="addLibButton">
<property name="text">
<string>Add library</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="addDirButton">
<property name="text">
<string>Add directory</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<item>

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'settings_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, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
QListView, QPushButton, QSizePolicy, QSpacerItem,
QSpinBox, QTabWidget, QVBoxLayout, QWidget)
class Ui_Settings(object):
def setupUi(self, Settings):
if not Settings.objectName():
Settings.setObjectName(u"Settings")
Settings.resize(400, 230)
self.verticalLayout = QVBoxLayout(Settings)
self.verticalLayout.setObjectName(u"verticalLayout")
self.tabWidget = QTabWidget(Settings)
self.tabWidget.setObjectName(u"tabWidget")
self.General = QWidget()
self.General.setObjectName(u"General")
self.formLayout = QFormLayout(self.General)
self.formLayout.setObjectName(u"formLayout")
self.logLevel = QComboBox(self.General)
self.logLevel.addItem("")
self.logLevel.addItem("")
self.logLevel.addItem("")
self.logLevel.addItem("")
self.logLevel.setObjectName(u"logLevel")
self.formLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.logLevel)
self.lableLogLevel = QLabel(self.General)
self.lableLogLevel.setObjectName(u"lableLogLevel")
self.formLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self.lableLogLevel)
self.labelSnapToGridSize = QLabel(self.General)
self.labelSnapToGridSize.setObjectName(u"labelSnapToGridSize")
self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.labelSnapToGridSize)
self.snapToGridSize = QSpinBox(self.General)
self.snapToGridSize.setObjectName(u"snapToGridSize")
self.snapToGridSize.setMinimum(1)
self.snapToGridSize.setMaximum(256)
self.snapToGridSize.setValue(4)
self.formLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.snapToGridSize)
self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.formLayout.setItem(2, QFormLayout.ItemRole.FieldRole, self.verticalSpacer)
self.tabWidget.addTab(self.General, "")
self.Libraries = QWidget()
self.Libraries.setObjectName(u"Libraries")
self.verticalLayout_2 = QVBoxLayout(self.Libraries)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.listView = QListView(self.Libraries)
self.listView.setObjectName(u"listView")
self.verticalLayout_2.addWidget(self.listView)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.addLibButton = QPushButton(self.Libraries)
self.addLibButton.setObjectName(u"addLibButton")
self.horizontalLayout.addWidget(self.addLibButton)
self.addDirButton = QPushButton(self.Libraries)
self.addDirButton.setObjectName(u"addDirButton")
self.horizontalLayout.addWidget(self.addDirButton)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.tabWidget.addTab(self.Libraries, "")
self.verticalLayout.addWidget(self.tabWidget)
self.buttonBox = QDialogButtonBox(Settings)
self.buttonBox.setObjectName(u"buttonBox")
self.buttonBox.setOrientation(Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.verticalLayout.addWidget(self.buttonBox)
self.retranslateUi(Settings)
self.buttonBox.accepted.connect(Settings.accept)
self.buttonBox.rejected.connect(Settings.reject)
self.tabWidget.setCurrentIndex(1)
QMetaObject.connectSlotsByName(Settings)
# setupUi
def retranslateUi(self, Settings):
Settings.setWindowTitle(QCoreApplication.translate("Settings", u"Settings", None))
self.logLevel.setItemText(0, QCoreApplication.translate("Settings", u"Debug", None))
self.logLevel.setItemText(1, QCoreApplication.translate("Settings", u"Info", None))
self.logLevel.setItemText(2, QCoreApplication.translate("Settings", u"Warning", None))
self.logLevel.setItemText(3, QCoreApplication.translate("Settings", u"Error", None))
self.lableLogLevel.setText(QCoreApplication.translate("Settings", u"Log level:", None))
self.labelSnapToGridSize.setText(QCoreApplication.translate("Settings", u"Snap-to-grid size:", None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.General), QCoreApplication.translate("Settings", u"General", None))
self.addLibButton.setText(QCoreApplication.translate("Settings", u"Add library", None))
self.addDirButton.setText(QCoreApplication.translate("Settings", u"Add directory", None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.Libraries), QCoreApplication.translate("Settings", u"Libraries", None))
# retranslateUi

View File

@@ -1,13 +1,18 @@
from __future__ import annotations
from math import ceil
from PySide6.QtCore import QRectF, QSize, Qt
from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap, QRegion
from bedit_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Icon, Line, LineType, Rectangle, Text
from bedit_gui.models import Ellipse, Icon, Line, LineType, Rectangle, Text
PORT_SIZE = 16
DEFAULT_ICON_SIZE = QSize(48, 48)
EMPTY_NATURAL_ICON_SIZE = QSize(32, 32)
ICON_MARGIN = 4
ICON_PREVIEW_OVERSAMPLE = 4
def get_bounding_box(icon: Icon) -> QRectF:
@@ -16,7 +21,7 @@ def get_bounding_box(icon: Icon) -> QRectF:
for shape in icon.shapes.values():
x, y = shape.pos
points.append((x, y))
if isinstance(shape, (Rectangle, Text)):
if isinstance(shape, (Rectangle, Ellipse, Text)):
points.append((x + shape.width, y + shape.height))
elif isinstance(shape, Line):
points.append(shape.end)
@@ -35,6 +40,19 @@ def get_bounding_box(icon: Icon) -> QRectF:
return QRectF(left, top, right - left, bottom - top)
def get_pixmap_bounding_box(pixmap: QPixmap) -> QRectF:
"""Return the bounds of the pixels actually painted in a transparent pixmap."""
bounds = QRegion(pixmap.mask()).boundingRect()
return QRectF(bounds) if not bounds.isEmpty() else QRectF(0, 0, pixmap.width(), pixmap.height())
def get_natural_icon_size(icon: Icon) -> QSize:
bounds = get_bounding_box(icon)
if bounds.isEmpty():
return QSize(EMPTY_NATURAL_ICON_SIZE)
return QSize(max(1, ceil(bounds.width()) + ICON_MARGIN), max(1, ceil(bounds.height()) + ICON_MARGIN))
def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon:
pixmap = QPixmap(size)
pixmap.fill(Qt.GlobalColor.transparent)
@@ -42,9 +60,7 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
if not icon.shapes and not icon.port_positions:
return QIcon(pixmap)
available_width = max(1, size.width() - 4)
available_height = max(1, size.height() - 4)
scale = min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
scale = _render_scale(bounds, size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(size.width() / 2, size.height() / 2)
@@ -56,6 +72,10 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
painter.setBrush(QBrush(_color(shape.fill_color)))
painter.drawRoundedRect(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), shape.corner_radius, shape.corner_radius)
elif isinstance(shape, Ellipse):
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
painter.setBrush(QBrush(_color(shape.fill_color)))
painter.drawEllipse(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height))
elif isinstance(shape, Text):
font = QFont()
font.setPixelSize(max(1, round(shape.size)))
@@ -82,6 +102,28 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
return QIcon(pixmap)
def render_fitted_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE) -> QIcon:
"""Render an icon preview with its painted content fitted to a uniform size."""
render_size = QSize(size.width() * ICON_PREVIEW_OVERSAMPLE, size.height() * ICON_PREVIEW_OVERSAMPLE)
rendered = render_icon(icon, ports, render_size).pixmap(render_size)
bounds = get_pixmap_bounding_box(rendered).toAlignedRect()
content = rendered.copy(bounds)
available = QSize(max(1, size.width() - ICON_MARGIN), max(1, size.height() - ICON_MARGIN))
fitted = content.scaled(available, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
pixmap = QPixmap(size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.drawPixmap((size.width() - fitted.width()) // 2, (size.height() - fitted.height()) // 2, fitted)
painter.end()
return QIcon(pixmap)
def _render_scale(bounds: QRectF, size: QSize) -> float:
available_width = max(1, size.width() - ICON_MARGIN)
available_height = max(1, size.height() - ICON_MARGIN)
return min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
if line_type is LineType.NONE:

View File

@@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
)
from bedit_core.models import Port, PortID
from bedit_gui.models import PortMetadata
from bedit_gui.views.port_editor_widget import PortEditorWidget
@@ -17,6 +18,7 @@ class InterfaceEditorDialog(QDialog):
def __init__(
self,
ports: dict[PortID, Port],
port_metadata: dict[PortID, PortMetadata] | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -25,7 +27,7 @@ class InterfaceEditorDialog(QDialog):
layout = QVBoxLayout(self)
self.editor = PortEditorWidget(self)
self.editor.set_ports(ports)
self.editor.set_ports(ports, port_metadata)
layout.addWidget(self.editor)
buttons = QDialogButtonBox(
@@ -38,3 +40,6 @@ class InterfaceEditorDialog(QDialog):
def ports(self) -> dict[PortID, Port]:
return self.editor.ports()
def port_metadata(self) -> dict[PortID, PortMetadata]:
return self.editor.port_metadata()

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
import logging
from pathlib import Path
from PySide6.QtWidgets import QDialog, QWidget
from PySide6.QtCore import QStringListModel, Qt
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import QAbstractItemView, QDialog, QFileDialog, QWidget
from bedit_gui.ui.generated.ui_settings_dialog import Ui_Settings
@@ -20,6 +23,8 @@ class SettingsDialog(QDialog):
def __init__(
self,
log_level: int,
snap_to_grid_size: int,
library_paths: list[str],
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -35,7 +40,46 @@ class SettingsDialog(QDialog):
self.ui.logLevel.setCurrentIndex(
selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO)
)
self.ui.snapToGridSize.setValue(snap_to_grid_size)
self._library_paths = QStringListModel(list(library_paths), self)
self.ui.listView.setModel(self._library_paths)
self.ui.listView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.ui.addLibButton.clicked.connect(self._add_library_file)
self.ui.addDirButton.clicked.connect(self._add_library_directory)
self._delete_shortcut = QShortcut(QKeySequence.StandardKey.Delete, self.ui.listView)
self._delete_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
self._delete_shortcut.activated.connect(self._delete_selected_paths)
@property
def log_level(self) -> int:
return int(self.ui.logLevel.currentData())
@property
def snap_to_grid_size(self) -> int:
return self.ui.snapToGridSize.value()
@property
def library_paths(self) -> list[str]:
return self._library_paths.stringList()
def _add_library_file(self) -> None:
path, _selected_filter = QFileDialog.getOpenFileName(self, "Add BEdit Library", "", "BEdit documents (*.bedit.json *.beb *.json)")
if path:
self._add_library_path(path)
def _add_library_directory(self) -> None:
path = QFileDialog.getExistingDirectory(self, "Add Library Directory")
if path:
self._add_library_path(path)
def _add_library_path(self, path: str) -> None:
normalized = str(Path(path).resolve())
paths = self._library_paths.stringList()
if normalized not in paths:
paths.append(normalized)
self._library_paths.setStringList(paths)
def _delete_selected_paths(self) -> None:
rows = sorted((index.row() for index in self.ui.listView.selectionModel().selectedRows()), reverse=True)
for row in rows:
self._library_paths.removeRow(row)

View File

@@ -3,7 +3,8 @@ from __future__ import annotations
from PySide6.QtCore import QEvent, QObject, QTimer, Qt, Signal
from PySide6.QtWidgets import QWidget
from bedit_core.models import Component, EquationImplementation
from bedit_core.models import Component, EquationImplementation, PortID
from bedit_gui.models import PortMetadata
from bedit_gui.ui.generated.ui_equation_editor_widget import Ui_equationEditorWidget
from bedit_gui.views.param_editor_widget import ParamEditorWidget
from bedit_gui.views.port_editor_widget import PortEditorWidget
@@ -14,6 +15,7 @@ class EquationEditorWidget(QWidget):
component_changed = Signal(object)
equation_text_change_requested = Signal(object, str, object, int)
port_metadata_change_requested = Signal(object, object)
sidebar_visible_changed = Signal(bool)
def __init__(self, parent: QWidget | None = None) -> None:
@@ -22,6 +24,7 @@ class EquationEditorWidget(QWidget):
self.ui = Ui_equationEditorWidget()
self.ui.setupUi(self)
self._component: Component | None = None
self._port_metadata: dict[PortID, PortMetadata] = {}
self._loading = False
self._edit_id = 0
self._edit_timer = QTimer(self)
@@ -45,11 +48,12 @@ class EquationEditorWidget(QWidget):
self._set_editors_enabled(False)
def set_component(self, component: Component | None) -> None:
def set_component(self, component: Component | None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
if component is not None and not isinstance(component.implementation, EquationImplementation):
raise TypeError("EquationEditorWidget only supports components with an equation implementation")
self._component = component
self._port_metadata = port_metadata or {}
self.refresh()
def component(self) -> Component | None:
@@ -88,7 +92,7 @@ class EquationEditorWidget(QWidget):
self.ui.initialEquationsTextEdit.setPlainText("\n".join(implementation.initial_equations))
self.ui.equationsTextEdit.setPlainText("\n".join(implementation.equations))
self.param_editor.set_params(component.parameters)
self.port_editor.set_ports(component.interface.ports)
self.port_editor.set_ports(component.interface.ports, self._port_metadata)
self._loading = False
self._set_editors_enabled(component is not None)
@@ -145,8 +149,21 @@ class EquationEditorWidget(QWidget):
def _ports_changed(self) -> None:
if self._loading or self._component is None:
return
self._component.interface.ports = self.port_editor.ports()
self.component_changed.emit(self._component)
ports = self.port_editor.ports()
metadata = self.port_editor.port_metadata()
if ports != self._component.interface.ports:
self._component.interface.ports = ports
self.component_changed.emit(self._component)
if metadata != self._port_metadata:
self._port_metadata = metadata
self.port_metadata_change_requested.emit(self._component, metadata)
def refresh_port_metadata(self, port_metadata: dict[PortID, PortMetadata]) -> None:
if port_metadata == self._port_metadata:
return
self._port_metadata = port_metadata
if self._component is not None:
self.port_editor.set_ports(self._component.interface.ports, port_metadata)
def _replace_placeholder(self, placeholder: QWidget, editor: QWidget) -> None:
self.ui.verticalLayout_2.replaceWidget(placeholder, editor)

View File

@@ -1,17 +1,47 @@
from __future__ import annotations
from PySide6.QtCore import QEvent, QObject, QRectF, Qt
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QWidget
import json
from enum import Enum
from itertools import pairwise
from math import hypot
from bedit_core.models import Component, GraphImplementation
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QTimer, Qt, Signal
from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsTextItem, QGraphicsView, QMenu, QWidget
from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort
from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon, PortMetadata
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_natural_icon_size, get_pixmap_bounding_box, render_icon
GRID_SPACING = 32
GRID_SPACING = 64
SCENE_SIZE = 10000
MIN_ZOOM = 0.2
MAX_ZOOM = 4.0
ZOOM_STEP = 1.15
ZOOM_TO_FIT_PADDING = 32.0
COMPONENT_LABEL_FONT_SIZE = 24.0
FALLBACK_COMPONENT_SPACING = 128
CONNECTION_WIDTH = 4.0
BOND_CONNECTION_COLOR = "#000000"
SIGNAL_CONNECTION_COLOR = "#00007f"
BOND_CONNECTION_BOUNDING_BOX_SPACING = 16.0
SIGNAL_CONNECTION_BOUNDING_BOX_SPACING = 0.0
CONNECTION_STRAIGHTEN_TOLERANCE = 8.0
ARROW_LENGTH = 32.0
ARROW_HALF_WIDTH = 16.0
SIGNAL_ARROW_LENGTH = ARROW_LENGTH / 2
SIGNAL_ARROW_HALF_WIDTH = ARROW_HALF_WIDTH / 2
CAUSALITY_TICK_HALF_LENGTH = 16.0
CONNECTION_ANNOTATION_FONT_SIZE = 18.0
CONNECTION_ANNOTATION_BACK_OFFSET = 24.0
CONNECTION_ANNOTATION_SIDE_OFFSET = 14.0
class GraphEditorMode(Enum):
NORMAL = "normal"
CONNECTION = "connection"
class GraphGraphicsScene(QGraphicsScene):
@@ -22,41 +52,781 @@ class GraphGraphicsScene(QGraphicsScene):
pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine)
painter.setPen(pen)
scene_rect = self.sceneRect()
left = int(rect.left()) - int(rect.left()) % GRID_SPACING
top = int(rect.top()) - int(rect.top()) % GRID_SPACING
x = left
while x <= rect.right():
painter.drawLine(x, rect.top(), x, rect.bottom())
painter.drawLine(x, scene_rect.top(), x, scene_rect.bottom())
x += GRID_SPACING
y = top
while y <= rect.bottom():
painter.drawLine(rect.left(), y, rect.right(), y)
painter.drawLine(scene_rect.left(), y, scene_rect.right(), y)
y += GRID_SPACING
class GraphConnectionItem(QGraphicsPathItem):
"""A routed connection with a full signal arrow or half bond arrow."""
def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None, connection_id: ConnectionID | None = None, editor: GraphEditorWidget | None = None) -> None:
super().__init__()
self.connection_id = connection_id
self.editor = editor
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
color = QColor(BOND_CONNECTION_COLOR if half_arrow else SIGNAL_CONNECTION_COLOR)
pen = QPen(color, CONNECTION_WIDTH)
self.setPen(pen)
self.setBrush(QBrush(color))
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setZValue(-1)
def contextMenuEvent(self, event) -> None:
if self.editor is None or self.connection_id is None:
return
if not self.isSelected():
self.scene().clearSelection()
self.setSelected(True)
menu = QMenu(self.editor)
add_point = menu.addAction("Add Point")
delete_connection = menu.addAction("Delete Connection")
selected = menu.exec(event.screenPos())
if selected is add_point:
self.editor.add_connection_point(self.connection_id, event.scenePos())
elif selected is delete_connection:
self.editor.delete_selected_connections()
event.accept()
@staticmethod
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
path = QPainterPath(QPointF(*points[0]))
for point in points[1:]:
path.lineTo(QPointF(*point))
target = QPointF(*points[-1])
previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None)
if previous is None:
return path
dx = target.x() - previous.x()
dy = target.y() - previous.y()
length = hypot(dx, dy)
arrow_length = ARROW_LENGTH if half_arrow else SIGNAL_ARROW_LENGTH
arrow_half_width = ARROW_HALF_WIDTH if half_arrow else SIGNAL_ARROW_HALF_WIDTH
back_x = target.x() - arrow_length * dx / length
back_y = target.y() - arrow_length * dy / length
perpendicular_x = -arrow_half_width * dy / length
perpendicular_y = arrow_half_width * dx / length
path.moveTo(target)
path.lineTo(back_x + perpendicular_x, back_y + perpendicular_y)
if not half_arrow:
path.lineTo(back_x - perpendicular_x, back_y - perpendicular_y)
path.closeSubpath()
if tick_at_source is not None:
GraphConnectionItem._add_causality_tick(path, points, tick_at_source)
return path
@staticmethod
def _add_causality_tick(path: QPainterPath, points: list[tuple[float, float]], at_source: bool) -> None:
if at_source:
endpoint = QPointF(*points[0])
neighbor = next((QPointF(*point) for point in points[1:] if QPointF(*point) != endpoint), None)
else:
endpoint = QPointF(*points[-1])
neighbor = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != endpoint), None)
if neighbor is None:
return
dx = neighbor.x() - endpoint.x()
dy = neighbor.y() - endpoint.y()
length = hypot(dx, dy)
perpendicular_x = -CAUSALITY_TICK_HALF_LENGTH * dy / length
perpendicular_y = CAUSALITY_TICK_HALF_LENGTH * dx / length
path.moveTo(endpoint.x() - perpendicular_x, endpoint.y() - perpendicular_y)
path.lineTo(endpoint.x() + perpendicular_x, endpoint.y() + perpendicular_y)
class GraphComponentItem(QGraphicsPixmapItem):
def __init__(self, component_id: ComponentID, pixmap, editor: GraphEditorWidget) -> None:
super().__init__(pixmap)
self.component_id = component_id
self.editor = editor
self._drag_start = QPointF()
self._dragging = False
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.component_id in self.editor._component_drag_starts and isinstance(value, QPointF):
grid_size = self.editor.snap_to_grid_size
value = QPointF(round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size)
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.editor.refresh_connections()
return result
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.editor.mode is GraphEditorMode.CONNECTION:
event.accept()
return
self._drag_start = QPointF(self.pos())
self._dragging = True
super().mousePressEvent(event)
self.editor.begin_component_move()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self.editor.mode is GraphEditorMode.CONNECTION:
if event.button() == Qt.MouseButton.LeftButton:
self.editor.choose_connection_component(self.component_id, event.screenPos())
event.accept()
return
super().mouseReleaseEvent(event)
self._dragging = False
self.editor.finish_component_moves()
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.editor.open_component(self.component_id)
event.accept()
return
super().mouseDoubleClickEvent(event)
def contextMenuEvent(self, event) -> None:
if not self.isSelected():
self.scene().clearSelection()
self.setSelected(True)
self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos())
event.accept()
class GraphComponentLabelItem(QGraphicsTextItem):
def __init__(self, component_id: ComponentID, text: str, label: GraphComponentLabel, component_item: GraphComponentItem, editor: GraphEditorWidget) -> None:
super().__init__(text, component_item)
self.component_id = component_id
self.editor = editor
self._dragging = False
self._drag_start = label.relative_position
font = self.font()
font.setItalic(True)
font.setPointSizeF(COMPONENT_LABEL_FONT_SIZE)
self.setFont(font)
self.setDefaultTextColor(QColor("#202020"))
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.set_relative_position(label.relative_position)
def relative_position(self) -> tuple[int, int]:
component_bounds = self.editor._component_bounds[self.component_id]
return round(self.pos().x() + self.boundingRect().width() / 2), round(self.pos().y() - component_bounds.bottom())
def set_relative_position(self, position: tuple[int, int]) -> None:
component_bounds = self.editor._component_bounds[self.component_id]
self.setPos(position[0] - self.boundingRect().width() / 2, component_bounds.bottom() + position[1])
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
component_bounds = self.editor._component_bounds[self.component_id]
size = self.editor.snap_to_grid_size
relative_x = value.x() + self.boundingRect().width() / 2
relative_y = value.y() - component_bounds.bottom()
value = QPointF(round(relative_x / size) * size - self.boundingRect().width() / 2, component_bounds.bottom() + round(relative_y / size) * size)
return super().itemChange(change, value)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._drag_start = self.relative_position()
self._dragging = True
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
super().mouseReleaseEvent(event)
self._dragging = False
position = self.relative_position()
self.set_relative_position(position)
if position != self._drag_start:
self.editor.finish_component_label_move(self.component_id, position)
def contextMenuEvent(self, event) -> None:
self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos())
event.accept()
class GraphConnectionPointItem(QGraphicsEllipseItem):
def __init__(self, connection_id: ConnectionID, index: int, position: tuple[int, int], editor: GraphEditorWidget) -> None:
radius = CONNECTION_WIDTH
super().__init__(-radius, -radius, radius * 2, radius * 2)
self.connection_id = connection_id
self.index = index
self.editor = editor
self._drag_start = QPointF()
self._dragging = False
self.setPos(*position)
self.setPen(QPen(Qt.PenStyle.NoPen))
self.setBrush(QBrush(QColor("#202020")))
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
size = self.editor.snap_to_grid_size
value = QPointF(round(value.x() / size) * size, round(value.y() / size) * size)
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.editor.refresh_connections()
return result
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._drag_start = QPointF(self.pos())
self._dragging = True
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
super().mouseReleaseEvent(event)
self._dragging = False
position = (round(self.pos().x()), round(self.pos().y()))
self.setPos(*position)
if self.pos() != self._drag_start:
self.editor.finish_connection_point_move(self.connection_id)
def contextMenuEvent(self, event) -> None:
menu = QMenu(self.editor)
delete_point = menu.addAction("Delete Point")
if menu.exec(event.screenPos()) is delete_point:
self.editor.delete_connection_point(self.connection_id, self.index)
event.accept()
class GraphEditorWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
component_moves_requested = Signal(object, object)
component_context_menu_requested = Signal(object, object)
component_open_requested = Signal(object)
component_label_move_requested = Signal(object, object, object)
connection_points_change_requested = Signal(object, object, object, str)
connection_add_requested = Signal(object, object)
connections_delete_requested = Signal(object, object)
component_drop_requested = Signal(object, object, object)
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
super().__init__(parent)
self.ui = Ui_graphEditorWidget()
self.ui.setupUi(self)
self._component: Component | None = None
self._graph = Graph()
self._icons: dict[ComponentID, Icon] = {}
self._port_metadata: dict[PortID, PortMetadata] = {}
self._mode = GraphEditorMode.NORMAL
self._connection_start: ComponentID | None = None
self._connection_preview: QGraphicsPathItem | None = None
self._component_items: dict[ComponentID, GraphComponentItem] = {}
self._component_drag_starts: dict[ComponentID, tuple[int, int]] = {}
self._component_bounds: dict[ComponentID, QRectF] = {}
self._component_label_items: dict[ComponentID, GraphComponentLabelItem] = {}
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {}
self._connection_annotation_items: dict[ConnectionID, QGraphicsTextItem] = {}
self.set_snap_to_grid_size(snap_to_grid_size)
self.scene = GraphGraphicsScene(self)
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
self.ui.graphicsView.setScene(self.scene)
self.ui.graphicsView.setRenderHint(QPainter.RenderHint.Antialiasing)
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.ui.graphicsView.viewport().installEventFilter(self)
self.ui.graphicsView.viewport().setAcceptDrops(True)
self._mode_actions = QActionGroup(self)
self._mode_actions.setExclusive(True)
self._mode_actions.addAction(self.ui.actionMouseMode)
self._mode_actions.addAction(self.ui.actionConnectionMode)
self.ui.actionMouseMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.NORMAL))
self.ui.actionConnectionMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.CONNECTION))
self.ui.actionMouseMode.setChecked(True)
self._mode_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self)
self._mode_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self._mode_shortcut.activated.connect(self.toggle_mode)
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
self.ui.graphicsView.viewport().setMouseTracking(True)
self.ui.graphicsView.centerOn(0, 0)
def set_component(self, component: Component | None) -> None:
@property
def snap_to_grid_size(self) -> int:
return self._snap_to_grid_size
def set_snap_to_grid_size(self, size: int) -> None:
if size < 1:
raise ValueError("snap-to-grid size must be positive")
self._snap_to_grid_size = size
@property
def mode(self) -> GraphEditorMode:
return self._mode
def set_mode(self, mode: GraphEditorMode) -> None:
if mode is self._mode:
return
self._mode = mode
self.ui.actionMouseMode.setChecked(mode is GraphEditorMode.NORMAL)
self.ui.actionConnectionMode.setChecked(mode is GraphEditorMode.CONNECTION)
self._clear_connection_start()
component = self._component
if component is not None:
self.set_component(component, self._graph, self._icons, self._port_metadata)
def toggle_mode(self) -> None:
self.set_mode(GraphEditorMode.CONNECTION if self._mode is GraphEditorMode.NORMAL else GraphEditorMode.NORMAL)
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
if component is not None and not isinstance(component.implementation, GraphImplementation):
raise TypeError("GraphEditorWidget only supports components with a graph implementation")
component_changed = component is not self._component
self._clear_connection_start()
self._component = component
self._graph = graph or Graph()
self._icons = icons or {}
self._port_metadata = port_metadata or {}
self._component_drag_starts = {}
self._component_items = {}
self._component_bounds = {}
self._component_label_items = {}
self._connection_items = {}
self._connection_point_items = {}
self._connection_annotation_items = {}
self.scene.clear()
if component is None:
return
graph = self._graph
icons = self._icons
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
for component_id, child in component.implementation.graph.components.items():
icon = icons.get(component_id, Icon())
icon_size = get_natural_icon_size(icon)
pixmap = render_icon(icon, child.interface.ports, icon_size, render_ports=self._mode is GraphEditorMode.CONNECTION).pixmap(icon_size)
item = GraphComponentItem(component_id, pixmap, self)
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
item.setPos(*positions[component_id])
item.setToolTip(child.name)
bounds = get_pixmap_bounding_box(pixmap)
self._component_items[component_id] = item
self._component_bounds[component_id] = bounds.translated(-pixmap.width() / 2, -pixmap.height() / 2)
self.scene.addItem(item)
for component_id, child in component.implementation.graph.components.items():
label = graph.component_labels.get(component_id, GraphComponentLabel())
if label.visible:
self._create_component_label_item(component_id, child.name, label)
for connection_id, connection in component.implementation.graph.connections.items():
if not isinstance(connection, (SignalConnection, BondConnection)):
continue
tick_at_source = None
if isinstance(connection, BondConnection):
if connection.causality is BondCausality.EFFORT_OUT:
tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source, connection_id=connection_id, editor=self)
connection_item.setData(0, str(connection_id))
self._connection_items[connection_id] = connection_item
self.scene.addItem(connection_item)
annotation = self._port_metadata.get(connection.target, PortMetadata()).connection_annotation if isinstance(connection, SignalConnection) else None
if annotation:
annotation_item = QGraphicsTextItem(annotation)
font = annotation_item.font()
font.setBold(True)
font.setPointSizeF(CONNECTION_ANNOTATION_FONT_SIZE)
annotation_item.setFont(font)
annotation_item.setDefaultTextColor(QColor(SIGNAL_CONNECTION_COLOR))
annotation_item.setZValue(1)
self._connection_annotation_items[connection_id] = annotation_item
self.scene.addItem(annotation_item)
visual_connection = graph.connections.get(connection_id)
self._create_connection_point_items(connection_id, visual_connection.points[1:-1] if visual_connection is not None and len(visual_connection.points) >= 2 else [])
self.refresh_connections()
if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
def refresh_connections(self) -> None:
component = self._component
if component is None or not self._connection_items:
return
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
for connection_id, item in self._connection_items.items():
connection = component.implementation.graph.connections.get(connection_id)
if connection is None:
continue
source_component = port_owners.get(connection.source)
target_component = port_owners.get(connection.target)
if source_component is None or target_component is None:
continue
source_position = self._item_position(source_component)
target_position = self._item_position(target_component)
point_items = self._connection_point_items.get(connection_id, [])
points = [source_position, *((point.pos().x(), point.pos().y()) for point in point_items), target_position]
points = self._straighten_direct_connection(points)
source_bounds = self._component_bounds[source_component].translated(*source_position)
target_bounds = self._component_bounds[target_component].translated(*target_position)
spacing = BOND_CONNECTION_BOUNDING_BOX_SPACING if isinstance(connection, BondConnection) else SIGNAL_CONNECTION_BOUNDING_BOX_SPACING
points = self._clip_connection(points, source_bounds, target_bounds, spacing)
tick_at_source = None
if isinstance(connection, BondConnection):
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
annotation_item = self._connection_annotation_items.get(connection_id)
if annotation_item is not None:
self._position_connection_annotation(annotation_item, points)
@staticmethod
def _position_connection_annotation(item: QGraphicsTextItem, points: list[tuple[float, float]]) -> None:
target = QPointF(*points[-1])
previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None)
if previous is None:
return
dx = target.x() - previous.x()
dy = target.y() - previous.y()
length = hypot(dx, dy)
x = target.x() - CONNECTION_ANNOTATION_BACK_OFFSET * dx / length - CONNECTION_ANNOTATION_SIDE_OFFSET * dy / length
y = target.y() - CONNECTION_ANNOTATION_BACK_OFFSET * dy / length + CONNECTION_ANNOTATION_SIDE_OFFSET * dx / length
bounds = item.boundingRect()
item.setPos(x - bounds.width() / 2, y - bounds.height() / 2)
def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None:
points = self._connection_metadata_points(connection_id)
position = self._snap_position(scene_position)
index = self._nearest_segment_index(points, position) + 1
points.insert(index, position)
self._request_connection_points_change(connection_id, points, "Add connection point")
def delete_connection_point(self, connection_id: ConnectionID, index: int) -> None:
points = self._connection_metadata_points(connection_id)
if 0 < index < len(points) - 1:
points.pop(index)
self._request_connection_points_change(connection_id, points, "Delete connection point")
def finish_connection_point_move(self, connection_id: ConnectionID) -> None:
self._request_connection_points_change(connection_id, self._connection_metadata_points(connection_id), "Move connection point")
def set_connection_points(self, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
if points is None:
self._graph.connections.pop(connection_id, None)
for item in self._connection_point_items.pop(connection_id, []):
self.scene.removeItem(item)
connection_item = self._connection_items.pop(connection_id, None)
if connection_item is not None:
self.scene.removeItem(connection_item)
annotation_item = self._connection_annotation_items.pop(connection_id, None)
if annotation_item is not None:
self.scene.removeItem(annotation_item)
return
self._graph.connections[connection_id] = GraphConnection(points=list(points))
interior_points = points[1:-1]
items = self._connection_point_items.get(connection_id, [])
if len(items) == len(interior_points):
for item, position in zip(items, interior_points):
item.setPos(*position)
else:
for item in items:
self.scene.removeItem(item)
self._create_connection_point_items(connection_id, interior_points)
self.refresh_connections()
def _create_connection_point_items(self, connection_id: ConnectionID, positions: list[tuple[int, int]]) -> None:
items = [GraphConnectionPointItem(connection_id, index, position, self) for index, position in enumerate(positions, 1)]
self._connection_point_items[connection_id] = items
for item in items:
self.scene.addItem(item)
def _connection_metadata_points(self, connection_id: ConnectionID) -> list[tuple[int, int]]:
component = self._component
if component is None:
return []
connection = component.implementation.graph.connections[connection_id]
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
source = self._item_position(port_owners[connection.source])
target = self._item_position(port_owners[connection.target])
interior = [(round(item.pos().x()), round(item.pos().y())) for item in self._connection_point_items.get(connection_id, [])]
return [(round(source[0]), round(source[1])), *interior, (round(target[0]), round(target[1]))]
def _request_connection_points_change(self, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
if self._component is not None:
self.connection_points_change_requested.emit(self._component, connection_id, points, text)
def _snap_position(self, position: QPointF) -> tuple[int, int]:
size = self.snap_to_grid_size
return round(position.x() / size) * size, round(position.y() / size) * size
@staticmethod
def _nearest_segment_index(points: list[tuple[int, int]], position: tuple[int, int]) -> int:
best_index = 0
best_distance = float("inf")
for index, (start, end) in enumerate(pairwise(points)):
dx = end[0] - start[0]
dy = end[1] - start[1]
length_squared = dx * dx + dy * dy
ratio = 0.0 if not length_squared else max(0.0, min(1.0, ((position[0] - start[0]) * dx + (position[1] - start[1]) * dy) / length_squared))
closest_x = start[0] + ratio * dx
closest_y = start[1] + ratio * dy
distance = (position[0] - closest_x) ** 2 + (position[1] - closest_y) ** 2
if distance < best_distance:
best_index = index
best_distance = distance
return best_index
def begin_component_move(self) -> None:
self._component_drag_starts = {item.component_id: (round(item.pos().x()), round(item.pos().y())) for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)}
def finish_component_moves(self) -> None:
starts = self._component_drag_starts
self._component_drag_starts = {}
if self._component is None:
return
positions = {}
for component_id, old_position in starts.items():
item = self._component_items[component_id]
position = (round(item.pos().x()), round(item.pos().y()))
item.setPos(*position)
if position != old_position:
positions[component_id] = position
if positions:
self.component_moves_requested.emit(self._component, positions)
def finish_component_label_move(self, component_id: ComponentID, relative_position: tuple[int, int]) -> None:
if self._component is not None:
self.component_label_move_requested.emit(self._component, component_id, relative_position)
def component_label_visible(self, component_id: ComponentID) -> bool:
return self._graph.component_labels.get(component_id, GraphComponentLabel()).visible
def set_component_label(self, component_id: ComponentID, label: GraphComponentLabel | None) -> None:
if label is None:
self._graph.component_labels.pop(component_id, None)
label = GraphComponentLabel()
else:
self._graph.component_labels[component_id] = label
item = self._component_label_items.pop(component_id, None)
if item is not None:
item.setParentItem(None)
self.scene.removeItem(item)
component = self._component
if component is not None and label.visible and component_id in component.implementation.graph.components:
self._create_component_label_item(component_id, component.implementation.graph.components[component_id].name, label)
def _create_component_label_item(self, component_id: ComponentID, text: str, label: GraphComponentLabel) -> None:
item = GraphComponentLabelItem(component_id, text, label, self._component_items[component_id], self)
self._component_label_items[component_id] = item
def set_component_position(self, component_id: ComponentID, position: tuple[int, int] | None) -> None:
item = self._component_items.get(component_id)
if item is None:
return
if position is None:
self._graph.component_positions.pop(component_id, None)
index = list(self._component_items).index(component_id)
position = (index * FALLBACK_COMPONENT_SPACING, 0)
else:
self._graph.component_positions[component_id] = position
item.setPos(*position)
self.refresh_connections()
def _item_position(self, component_id: ComponentID) -> tuple[float, float]:
position = self._component_items[component_id].pos()
return position.x(), position.y()
@staticmethod
def _straighten_direct_connection(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
if len(points) != 2:
return points
source, target = points
dx = target[0] - source[0]
dy = target[1] - source[1]
if abs(dx) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dx) < abs(dy):
x = (source[0] + target[0]) / 2
return [(x, source[1]), (x, target[1])]
if abs(dy) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dy) < abs(dx):
y = (source[1] + target[1]) / 2
return [(source[0], y), (target[0], y)]
return points
@staticmethod
def _clip_connection(points: list[tuple[float, float]], source_bounds: QRectF, target_bounds: QRectF, spacing: float) -> list[tuple[float, float]]:
source = points[0]
target = points[-1]
source_direction = next((point for point in points[1:] if point != source), None)
target_direction = next((point for point in reversed(points[:-1]) if point != target), None)
if source_direction is None or target_direction is None:
return points
clipped = list(points)
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source, source_direction, spacing)
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target, target_direction, spacing)
return clipped
@staticmethod
def _bounding_box_edge(bounds: QRectF, origin: tuple[float, float], toward: tuple[float, float], spacing: float) -> tuple[float, float]:
bounds = bounds.adjusted(-spacing, -spacing, spacing, spacing)
dx = toward[0] - origin[0]
dy = toward[1] - origin[1]
horizontal_scale = (bounds.right() - origin[0]) / dx if dx > 0 else (bounds.left() - origin[0]) / dx if dx < 0 else float("inf")
vertical_scale = (bounds.bottom() - origin[1]) / dy if dy > 0 else (bounds.top() - origin[1]) / dy if dy < 0 else float("inf")
scale = min(horizontal_scale, vertical_scale)
return origin[0] + dx * scale, origin[1] + dy * scale
def component(self) -> Component | None:
return self._component
def selected_component_ids(self) -> list[ComponentID]:
return [item.component_id for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)]
def selected_connection_ids(self) -> list[ConnectionID]:
return [item.connection_id for item in self.scene.selectedItems() if isinstance(item, GraphConnectionItem) and item.connection_id is not None]
def delete_selected_connections(self) -> None:
if self._component is not None:
self.connections_delete_requested.emit(self._component, self.selected_connection_ids())
def open_component(self, component_id: ComponentID) -> None:
self._clear_connection_start()
self.component_open_requested.emit(component_id)
def choose_connection_component(self, component_id: ComponentID, screen_position) -> None:
if self._component is None or self._mode is not GraphEditorMode.CONNECTION:
return
if self._connection_start is None:
self.scene.clearSelection()
self._component_items[component_id].setSelected(True)
self._connection_start = component_id
self._connection_preview = QGraphicsPathItem()
self._connection_preview.setPen(QPen(QColor("#606060"), CONNECTION_WIDTH, Qt.PenStyle.DashLine))
self._connection_preview.setZValue(-0.5)
self.scene.addItem(self._connection_preview)
return
start = self._connection_start
self._clear_connection_start()
self.scene.clearSelection()
if start == component_id:
return
options = self._connection_options(start, component_id)
if len(options) == 1:
self.connection_add_requested.emit(self._component, options[0][1])
return
if not options:
return
menu = QMenu(self)
actions = []
for label, connection, _preferred in options:
action = menu.addAction(label)
actions.append((action, connection))
menu.setActiveAction(actions[0][0])
selected = menu.exec(screen_position)
for action, connection in actions:
if selected is action:
self.connection_add_requested.emit(self._component, connection)
break
def _update_connection_preview(self, mouse_position: QPointF) -> None:
if self._connection_start is None or self._connection_preview is None:
return
source = self._item_position(self._connection_start)
if mouse_position == QPointF(*source):
self._connection_preview.setPath(QPainterPath(mouse_position))
return
bounds = self._component_bounds[self._connection_start].translated(*source)
spacing = max(BOND_CONNECTION_BOUNDING_BOX_SPACING, SIGNAL_CONNECTION_BOUNDING_BOX_SPACING)
start = self._bounding_box_edge(bounds, source, (mouse_position.x(), mouse_position.y()), spacing)
path = QPainterPath(QPointF(*start))
path.lineTo(mouse_position)
self._connection_preview.setPath(path)
def _clear_connection_start(self) -> None:
self._connection_start = None
if self._connection_preview is not None and self._connection_preview.scene() is self.scene:
self.scene.removeItem(self._connection_preview)
self._connection_preview = None
def _connection_options(self, first_id: ComponentID, second_id: ComponentID) -> list[tuple[str, Connection, bool]]:
if self._component is None:
return []
components = self._component.implementation.graph.components
first = components[first_id]
second = components[second_id]
used_ports = {port_id for connection in self._component.implementation.graph.connections.values() for port_id in (connection.source, connection.target)}
options = []
for first_port_id, first_port in first.interface.ports.items():
for second_port_id, second_port in second.interface.ports.items():
if not self._compatible_ports(first_port, second_port) or not self._port_available(first_port_id, first_port, used_ports) or not self._port_available(second_port_id, second_port, used_ports):
continue
preferred = first_port.direction is SignalDirection.OUTPUT and second_port.direction is SignalDirection.INPUT
reverse_preferred = second_port.direction is SignalDirection.OUTPUT and first_port.direction is SignalDirection.INPUT
if reverse_preferred:
source_id, source_port, source_name = second_port_id, second_port, second.name
target_id, target_port, target_name = first_port_id, first_port, first.name
else:
source_id, source_port, source_name = first_port_id, first_port, first.name
target_id, target_port, target_name = second_port_id, second_port, second.name
connection_type = SignalConnection if isinstance(source_port, SignalPort) else BondConnection
label = f"{source_name}.{source_port.name}{target_name}.{target_port.name}"
options.append((label, connection_type(source=source_id, target=target_id), preferred or reverse_preferred))
return sorted(options, key=lambda option: not option[2])
@staticmethod
def _compatible_ports(first: Port, second: Port) -> bool:
if isinstance(first, SignalPort) and isinstance(second, SignalPort):
return first.direction is not second.direction
if isinstance(first, BondPort) and isinstance(second, BondPort):
return not first.domain or not second.domain or first.domain == second.domain
return False
@staticmethod
def _port_available(port_id: PortID, port: Port, used_ports: set[PortID]) -> bool:
if isinstance(port, SignalPort):
return port.direction is SignalDirection.OUTPUT or port.multiplicity or port_id not in used_ports
if isinstance(port, BondPort):
return port.multiplicity or port_id not in used_ports
return False
def paste_position(self) -> tuple[int, int]:
viewport = self.ui.graphicsView.viewport()
viewport_position = viewport.mapFromGlobal(QCursor.pos())
if not viewport.rect().contains(viewport_position):
viewport_position = viewport.rect().center()
scene_position = self.ui.graphicsView.mapToScene(viewport_position)
size = self.snap_to_grid_size
return round(scene_position.x() / size) * size, round(scene_position.y() / size) * size
def zoom_to_fit(self) -> None:
bounds = self.scene.itemsBoundingRect()
if bounds.isEmpty():
self.ui.graphicsView.resetTransform()
self.ui.graphicsView.centerOn(0, 0)
return
bounds.adjust(-ZOOM_TO_FIT_PADDING, -ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING)
self.ui.graphicsView.fitInView(bounds, Qt.AspectRatioMode.KeepAspectRatio)
current_zoom = self.ui.graphicsView.transform().m11()
target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, current_zoom))
if target_zoom != current_zoom:
self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom)
self.ui.graphicsView.centerOn(bounds.center())
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.ui.graphicsView.viewport() and event.type() in (QEvent.Type.DragEnter, QEvent.Type.DragMove):
assert isinstance(event, (QDragEnterEvent, QDragMoveEvent))
if self._component is not None and event.mimeData().hasFormat(COMPONENTS_MIME):
event.setDropAction(Qt.DropAction.CopyAction)
event.accept()
return True
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Drop:
assert isinstance(event, QDropEvent)
if self._component is not None and event.mimeData().hasFormat(COMPONENTS_MIME):
try:
payload = json.loads(bytes(event.mimeData().data(COMPONENTS_MIME)).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return True
if isinstance(payload, dict):
position = self._snap_position(self.ui.graphicsView.mapToScene(event.position().toPoint()))
self.component_drop_requested.emit(self._component, payload, position)
event.setDropAction(Qt.DropAction.CopyAction)
event.accept()
return True
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.MouseMove:
assert isinstance(event, QMouseEvent)
self._update_connection_preview(self.ui.graphicsView.mapToScene(event.position().toPoint()))
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
assert isinstance(event, QWheelEvent)
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:

View File

@@ -11,7 +11,7 @@ from bedit_gui.models import Icon, Shape, ShapeID
from bedit_gui.services import icon_files
from bedit_gui.services.application_logging import get_logger
from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor
from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, LineCreationTool, RectangleCreationTool, TextCreationTool
from bedit_gui.views.icon_graphics_scene import EllipseCreationTool, IconGraphicsScene, LineCreationTool, RectangleCreationTool, TextCreationTool
from bedit_gui.views.shape_options_dialog import ShapeOptionsDialog
logger = get_logger(__name__)
@@ -123,6 +123,8 @@ class IconEditorWindow(QMainWindow):
self.ui.graphicsView.viewport().installEventFilter(self)
self.ui.actionAdd_Rectangle.setCheckable(True)
self.ui.actionAdd_Rectangle.triggered.connect(self._start_rectangle_tool)
self.ui.actionAdd_Circle.setCheckable(True)
self.ui.actionAdd_Circle.triggered.connect(self._start_ellipse_tool)
self.ui.actionAdd_Text.setCheckable(True)
self.ui.actionAdd_Text.triggered.connect(self._start_text_tool)
self.ui.actionAdd_Line.setCheckable(True)
@@ -306,14 +308,24 @@ class IconEditorWindow(QMainWindow):
def _start_rectangle_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.ui.actionAdd_Circle.setChecked(False)
self.ui.actionAdd_Text.setChecked(False)
self.ui.actionAdd_Line.setChecked(False)
self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer))
self.ui.actionAdd_Rectangle.setChecked(True)
def _start_ellipse_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.ui.actionAdd_Rectangle.setChecked(False)
self.ui.actionAdd_Text.setChecked(False)
self.ui.actionAdd_Line.setChecked(False)
self.scene.set_creation_tool(EllipseCreationTool(self.scene, layer))
self.ui.actionAdd_Circle.setChecked(True)
def _start_text_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.ui.actionAdd_Rectangle.setChecked(False)
self.ui.actionAdd_Circle.setChecked(False)
self.ui.actionAdd_Line.setChecked(False)
self.scene.set_creation_tool(TextCreationTool(self.scene, layer))
self.ui.actionAdd_Text.setChecked(True)
@@ -321,6 +333,7 @@ class IconEditorWindow(QMainWindow):
def _start_line_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.ui.actionAdd_Rectangle.setChecked(False)
self.ui.actionAdd_Circle.setChecked(False)
self.ui.actionAdd_Text.setChecked(False)
self.scene.set_creation_tool(LineCreationTool(self.scene, layer))
self.ui.actionAdd_Line.setChecked(True)
@@ -328,6 +341,7 @@ class IconEditorWindow(QMainWindow):
def _tool_active_changed(self, active: bool) -> None:
if not active:
self.ui.actionAdd_Rectangle.setChecked(False)
self.ui.actionAdd_Circle.setChecked(False)
self.ui.actionAdd_Text.setChecked(False)
self.ui.actionAdd_Line.setChecked(False)
cursor = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor

View File

@@ -6,10 +6,12 @@ from typing import Protocol
from PySide6.QtCore import QLineF, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget
from bedit_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Icon, Line, LineType, Rectangle, Shape, ShapeID, Text
from bedit_gui.models import Ellipse, Icon, Line, LineType, Rectangle, Shape, ShapeID, Text
ICON_SCENE_SIZE = 512
class ShapeCreationTool(Protocol):
@@ -24,7 +26,7 @@ class RectangleCreationTool:
self.scene = scene
self.layer = layer
self.start: QPointF | None = None
self.preview: QGraphicsRectItem | None = None
self.preview: QGraphicsRectItem | QGraphicsEllipseItem | None = None
def begin(self, position: QPointF) -> None:
position = self._bounded(position)
@@ -67,6 +69,16 @@ class TextCreationTool(RectangleCreationTool):
return Text(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height(), text="Text")
class EllipseCreationTool(RectangleCreationTool):
def begin(self, position: QPointF) -> None:
position = self._bounded(position)
self.start = position
self.preview = self.scene.addEllipse(QRectF(position, position), QPen(Qt.PenStyle.DashLine))
def create_shape(self, rect: QRectF) -> Shape:
return Ellipse(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height())
class LineCreationTool:
def __init__(self, scene: QGraphicsScene, layer: int) -> None:
self.scene = scene
@@ -107,7 +119,8 @@ class LineCreationTool:
class ShapeGraphicsItem(QGraphicsPathItem):
handle_size = 6.0
handle_size = 8.0
handle_hit_size = 16.0
def __init__(self, shape_id: ShapeID, shape: Shape, scene: IconGraphicsScene) -> None:
super().__init__()
@@ -126,13 +139,24 @@ class ShapeGraphicsItem(QGraphicsPathItem):
def resize_handles(self) -> dict[str, QRectF]:
return {"size": self.resize_handle_rect()}
def resize_handle_hit_rects(self) -> dict[str, QRectF]:
size = self.handle_hit_size
return {name: QRectF(rect.center().x() - size / 2, rect.center().y() - size / 2, size, size) for name, rect in self.resize_handles().items()}
def boundingRect(self) -> QRectF:
margin = self.handle_size / 2
margin = self.handle_hit_size / 2
return super().boundingRect().adjusted(-margin, -margin, margin, margin)
def shape(self) -> QPainterPath:
path = super().shape()
if self.isSelected():
for rect in self.resize_handle_hit_rects().values():
path.addRect(rect)
return path
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._original_shape = self.current_shape()
self._resize_handle = next((name for name, rect in self.resize_handles().items() if self.isSelected() and rect.contains(event.pos())), None)
self._resize_handle = next((name for name, rect in self.resize_handle_hit_rects().items() if self.isSelected() and rect.contains(event.pos())), None)
if self._resize_handle is not None:
event.accept()
return
@@ -224,6 +248,37 @@ class RectangleGraphicsItem(ShapeGraphicsItem):
self.setPath(path)
class EllipseGraphicsItem(ShapeGraphicsItem):
def __init__(self, shape_id: ShapeID, shape: Ellipse, scene: IconGraphicsScene) -> None:
super().__init__(shape_id, shape, scene)
self.ellipse = deepcopy(shape)
self.setPos(shape.pos[0], shape.pos[1])
self._set_size(shape.width, shape.height)
self.setPen(scene._pen(shape))
self.setBrush(QBrush(scene._color(shape.fill_color)))
self.setZValue(shape.layer)
def current_shape(self) -> Ellipse:
shape = deepcopy(self.ellipse)
shape.pos = (round(self.pos().x()), round(self.pos().y()))
rect = self.path().boundingRect()
shape.width = rect.width()
shape.height = rect.height()
return shape
def resize_to(self, position: QPointF, _handle: str) -> None:
bounds = self.icon_scene.sceneRect()
width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x())
height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y())
self._set_size(width, height)
def _set_size(self, width: float, height: float) -> None:
self.prepareGeometryChange()
path = QPainterPath()
path.addEllipse(QRectF(0, 0, width, height))
self.setPath(path)
class TextGraphicsItem(ShapeGraphicsItem):
def __init__(self, shape_id: ShapeID, shape: Text, scene: IconGraphicsScene) -> None:
super().__init__(shape_id, shape, scene)
@@ -356,7 +411,7 @@ class IconGraphicsScene(QGraphicsScene):
super().__init__(parent)
self._tool: ShapeCreationTool | None = None
self._ports: dict[PortID, Port] = {}
self.setSceneRect(-64, -64, 128, 128)
self.setSceneRect(-ICON_SCENE_SIZE / 2, -ICON_SCENE_SIZE / 2, ICON_SCENE_SIZE, ICON_SCENE_SIZE)
def set_ports(self, ports: dict[PortID, Port]) -> None:
self._ports = deepcopy(ports)
@@ -438,13 +493,15 @@ class IconGraphicsScene(QGraphicsScene):
def _add_shape_item(self, shape_id: ShapeID, shape: Shape) -> None:
if isinstance(shape, Rectangle):
self.addItem(RectangleGraphicsItem(shape_id, shape, self))
elif isinstance(shape, Ellipse):
self.addItem(EllipseGraphicsItem(shape_id, shape, self))
elif isinstance(shape, Text):
self.addItem(TextGraphicsItem(shape_id, shape, self))
elif isinstance(shape, Line):
self.addItem(LineGraphicsItem(shape_id, shape, self))
@staticmethod
def _pen(shape: Rectangle) -> QPen:
def _pen(shape: Rectangle | Ellipse) -> QPen:
return IconGraphicsScene._line_pen(shape.line_type, shape.line_thickness, shape.line_color)
@staticmethod

View File

@@ -7,7 +7,7 @@ from bedit_gui.views.graph_editor_widget import GraphEditorWidget
class MainWindow(QMainWindow):
def __init__(self) -> None:
def __init__(self, snap_to_grid_size: int = 4) -> None:
super().__init__()
self.ui = Ui_MainWindow()
@@ -18,7 +18,7 @@ class MainWindow(QMainWindow):
self.equation_editor = EquationEditorWidget(self.ui.centralwidget)
self.equation_editor.hide()
central_layout.addWidget(self.equation_editor)
self.graph_editor = GraphEditorWidget(self.ui.centralwidget)
self.graph_editor = GraphEditorWidget(self.ui.centralwidget, snap_to_grid_size)
self.graph_editor.hide()
central_layout.addWidget(self.graph_editor)

View File

@@ -22,19 +22,21 @@ class DocumentTreeModel(QAbstractItemModel):
rename_document_requested = Signal(str)
rename_component_requested = Signal(Component, str)
def __init__(self) -> None:
def __init__(self, editable: bool = True) -> None:
super().__init__()
self._document: CoreDocument | None = None
self._editable = editable
self._root = DocumentTreeNode("Document", None, None, None, [])
self._component_icons: dict[ComponentID, QIcon] = {}
self._component_nodes: dict[ComponentID, DocumentTreeNode] = {}
def set_document(self, document: CoreDocument) -> None:
self.set_documents([document])
def set_documents(self, documents: list[CoreDocument]) -> None:
self.beginResetModel()
self._document = document
self._component_icons = {}
self._component_nodes = {}
self._root = self._build_tree(document)
self._root = self._build_tree(documents)
self.endResetModel()
def set_component_icon(self, component_id: ComponentID, icon: QIcon) -> None:
@@ -131,6 +133,12 @@ class DocumentTreeModel(QAbstractItemModel):
node = index.internalPointer()
return node.value if isinstance(node, DocumentTreeNode) else None
def component_index(self, component_id: ComponentID) -> QModelIndex:
node = self._component_nodes.get(component_id)
if node is None or node.parent is None:
return QModelIndex()
return self.createIndex(node.parent.children.index(node), 0, node)
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
flags = super().flags(index)
@@ -140,12 +148,12 @@ class DocumentTreeModel(QAbstractItemModel):
node = index.internalPointer()
# Make the document root node editable
if index.column() == 0 and isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
if self._editable and index.column() == 0 and isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
flags |= Qt.ItemFlag.ItemIsEditable
return flags
def _build_tree(self, document: CoreDocument) -> DocumentTreeNode:
def _build_tree(self, documents: list[CoreDocument]) -> DocumentTreeNode:
# QT's invisible root
root = DocumentTreeNode(
name="",
@@ -154,19 +162,18 @@ class DocumentTreeModel(QAbstractItemModel):
parent=None,
children=[],
)
# Add itself as a child so the document root is visible in the tree
document_root = DocumentTreeNode(name=document.name, value=document, component_id=None, parent=root, children=[])
root.children.append(document_root)
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
def _list_children(parent: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
for component_id, component in sorted(components.items(), key=lambda item: (item[1].name.casefold(), item[1].name, str(item[0]))):
component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=root, children=[])
root.children.append(component_node)
component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=parent, children=[])
parent.children.append(component_node)
self._component_nodes[component_id] = component_node
if isinstance(component.implementation, GraphImplementation):
_list_children(component_node, component.implementation.graph.components)
_list_children(document_root, document.root)
for document in documents:
document_root = DocumentTreeNode(name=document.name, value=document, component_id=None, parent=root, children=[])
root.children.append(document_root)
_list_children(document_root, document.root)
return root

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import json
from collections.abc import Callable
from PySide6.QtCore import QMimeData, QModelIndex, Qt
from bedit_core.models import Component
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
PayloadFactory = Callable[[list[Component]], dict]
class LibraryTreeModel(DocumentTreeModel):
def __init__(self, payload_factory: PayloadFactory) -> None:
super().__init__(editable=False)
self._payload_factory = payload_factory
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
flags = super().flags(index)
if isinstance(self.value(index), Component):
flags |= Qt.ItemFlag.ItemIsDragEnabled
return flags
def mimeTypes(self) -> list[str]:
return [COMPONENTS_MIME]
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData:
rows = [index for index in indexes if index.column() == 0 and isinstance(self.value(index), Component)]
selected = {id(self.value(index)) for index in rows}
components = []
added = set()
for index in rows:
parent = index.parent()
if any(id(self.value(parent_index)) in selected for parent_index in self._parents(parent)):
continue
component = self.value(index)
if isinstance(component, Component) and id(component) not in added:
components.append(component)
added.add(id(component))
mime = QMimeData()
if components:
mime.setData(COMPONENTS_MIME, json.dumps(self._payload_factory(components)).encode("utf-8"))
mime.setText("\n".join(component.name for component in components))
return mime
def supportedDragActions(self) -> Qt.DropAction:
return Qt.DropAction.CopyAction
@staticmethod
def _parents(index: QModelIndex):
while index.isValid():
yield index
index = index.parent()

View File

@@ -7,6 +7,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import QButtonGroup, QLayout, QWidget
from bedit_core.models import BondPort, Port, PortCausality, PortID, SignalDirection, SignalPort, ValueType
from bedit_gui.models import PortMetadata
from bedit_gui.ui.generated.ui_port_editor_widget import Ui_PortEditor
@@ -21,6 +22,7 @@ class PortEditorWidget(QWidget):
self.ui = Ui_PortEditor()
self.ui.setupUi(self)
self._ports: dict[PortID, Port] = {}
self._port_metadata: dict[PortID, PortMetadata] = {}
self._port_ids: list[PortID] = []
self._loading = False
@@ -54,6 +56,7 @@ class PortEditorWidget(QWidget):
self.ui.widthSize.valueChanged.connect(self._form_changed)
self.ui.heightSize.valueChanged.connect(self._form_changed)
self.ui.multiplicityCheckBox.toggled.connect(self._form_changed)
self.ui.connectionAnnotationEdit.textChanged.connect(self._form_changed)
self.ui.signalTypeComboBox.currentIndexChanged.connect(self._form_changed)
self.ui.domainComboBox.currentTextChanged.connect(self._form_changed)
self.ui.causalityComboBox.currentIndexChanged.connect(self._form_changed)
@@ -62,14 +65,18 @@ class PortEditorWidget(QWidget):
self._set_editor_enabled(False)
self._update_option_visibility()
def set_ports(self, ports: dict[PortID, Port]) -> None:
def set_ports(self, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None:
self._ports = deepcopy(ports)
self._port_metadata = deepcopy(port_metadata or {})
self._port_ids = list(self._ports)
self._rebuild_list()
def ports(self) -> dict[PortID, Port]:
return deepcopy(self._ports)
def port_metadata(self) -> dict[PortID, PortMetadata]:
return deepcopy(self._port_metadata)
def _rebuild_list(self, selected_id: PortID | None = None) -> None:
self._list_model.clear()
for port_id in self._port_ids:
@@ -90,7 +97,7 @@ class PortEditorWidget(QWidget):
self.ui.removePort.setEnabled(port_id is not None)
self._set_editor_enabled(port_id is not None)
if port_id is not None:
self._load_port(self._ports[port_id])
self._load_port(self._ports[port_id], self._port_metadata.get(port_id, PortMetadata()))
def _selected_port_id(self) -> PortID | None:
index = self.ui.portList.currentIndex()
@@ -98,7 +105,7 @@ class PortEditorWidget(QWidget):
return None
return self._port_ids[index.row()]
def _load_port(self, port: Port) -> None:
def _load_port(self, port: Port, metadata: PortMetadata) -> None:
self._loading = True
self.ui.nameEdit.setText(port.name)
self.ui.inputOrientation.setChecked(port.direction is SignalDirection.INPUT)
@@ -106,6 +113,7 @@ class PortEditorWidget(QWidget):
self.ui.widthSize.setValue(port.matrix_size[0])
self.ui.heightSize.setValue(port.matrix_size[1])
self.ui.multiplicityCheckBox.setChecked(port.multiplicity)
self.ui.connectionAnnotationEdit.setText(metadata.connection_annotation or "")
self.ui.descriptionEdit.setPlainText(port.description or "")
if isinstance(port, SignalPort):
@@ -135,6 +143,12 @@ class PortEditorWidget(QWidget):
old_port = self._ports[port_id]
self._ports[port_id] = self._port_from_form(old_port)
annotation = (self.ui.connectionAnnotationEdit.text().strip() or None) if self.ui.multiplicityCheckBox.isChecked() else None
metadata = PortMetadata(connection_annotation=annotation)
if metadata == PortMetadata():
self._port_metadata.pop(port_id, None)
else:
self._port_metadata[port_id] = metadata
self._list_model.item(self._port_ids.index(port_id)).setText(
self._ports[port_id].name
)
@@ -188,6 +202,7 @@ class PortEditorWidget(QWidget):
return
row = self._port_ids.index(port_id)
del self._ports[port_id]
self._port_metadata.pop(port_id, None)
self._port_ids.remove(port_id)
selected = (
self._port_ids[min(row, len(self._port_ids) - 1)]
@@ -201,6 +216,9 @@ class PortEditorWidget(QWidget):
signal = self.ui.typeSignal.isChecked()
self._set_layout_visible(self.ui.signalOptions, signal)
self._set_layout_visible(self.ui.bondOptions, not signal)
annotation_visible = self.ui.multiplicityCheckBox.isChecked()
self.ui.connectionAnnotationLabel.setVisible(annotation_visible)
self.ui.connectionAnnotationEdit.setVisible(annotation_visible)
@staticmethod
def _set_layout_visible(layout: QLayout, visible: bool) -> None:

View File

@@ -5,7 +5,7 @@ from copy import deepcopy
from PySide6.QtCore import QRectF
from PySide6.QtWidgets import QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QLabel, QLineEdit, QSpinBox, QVBoxLayout, QWidget
from bedit_gui.models import Line, LineType, Rectangle, Shape, Text
from bedit_gui.models import Ellipse, Line, LineType, Rectangle, Shape, Text
from bedit_gui.views.color_button import ColorButton
@@ -34,6 +34,8 @@ class ShapeOptionsDialog(QDialog):
if isinstance(shape, Rectangle):
self._add_rectangle_fields(shape)
elif isinstance(shape, Ellipse):
self._add_ellipse_fields(shape)
elif isinstance(shape, Text):
self._add_text_fields(shape)
elif isinstance(shape, Line):
@@ -51,6 +53,10 @@ class ShapeOptionsDialog(QDialog):
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
return Rectangle(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), corner_radius=self.corner_radius.value(), line_color=self.line_color.color(), fill_color=self.fill_color.color())
if isinstance(self._shape, Ellipse):
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
return Ellipse(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), line_color=self.line_color.color(), fill_color=self.fill_color.color())
if isinstance(self._shape, Text):
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
@@ -89,6 +95,29 @@ class ShapeOptionsDialog(QDialog):
self.form.addRow("Line color", self.line_color)
self.form.addRow("Fill color", self.fill_color)
def _add_ellipse_fields(self, shape: Ellipse) -> None:
self.width = QSpinBox()
self.width.setRange(1, round(self._scene_rect.width()))
self.width.setValue(round(shape.width))
self.height = QSpinBox()
self.height.setRange(1, round(self._scene_rect.height()))
self.height.setValue(round(shape.height))
self.line_type = QComboBox()
for line_type in LineType:
self.line_type.addItem(line_type.value.replace("_", " ").title(), line_type)
self.line_type.setCurrentIndex(self.line_type.findData(shape.line_type))
self.line_thickness = QDoubleSpinBox()
self.line_thickness.setRange(0, 1000)
self.line_thickness.setValue(shape.line_thickness)
self.line_color = ColorButton(shape.line_color)
self.fill_color = ColorButton(shape.fill_color)
self.form.addRow("Width", self.width)
self.form.addRow("Height", self.height)
self.form.addRow("Line type", self.line_type)
self.form.addRow("Line thickness", self.line_thickness)
self.form.addRow("Line color", self.line_color)
self.form.addRow("Fill color", self.fill_color)
def _add_text_fields(self, shape: Text) -> None:
self.width = QSpinBox()
self.width.setRange(1, round(self._scene_rect.width()))

View File

@@ -24,6 +24,14 @@ class ProcessResult:
stdout: str
stderr: str
def diagnostics(self) -> str:
sections = []
if self.stdout.strip():
sections.append(f"OpenModelica output:\n{self.stdout.strip()}")
if self.stderr.strip():
sections.append(f"OpenModelica errors:\n{self.stderr.strip()}")
return "\n\n".join(sections)
class OpenModelicaError(RuntimeError):
"""Raised when OMC cannot start or reports a failure."""
@@ -80,7 +88,7 @@ class OpenModelicaRunner:
f"OpenModelica timed out after {error.timeout} seconds"
) from error
if result.return_code != 0:
details = result.stderr.strip() or result.stdout.strip()
details = result.diagnostics()
suffix = f": {details}" if details else ""
raise OpenModelicaError(
f"OpenModelica exited with status {result.return_code}{suffix}"
@@ -128,7 +136,7 @@ class OpenModelicaRunner:
continue
result = ProcessResult(command=tuple(command), return_code=process.returncode, stdout=stdout, stderr=stderr)
if result.return_code != 0:
details = result.stderr.strip() or result.stdout.strip()
details = result.diagnostics()
suffix = f": {details}" if details else ""
raise OpenModelicaError(f"simulation exited with status {result.return_code}{suffix}")
return result

View File

@@ -27,6 +27,7 @@ from .results import SimulationResult, load_openmodelica_csv
_MODEL_FILE = "model.mo"
_RUN_SCRIPT_FILE = "run.mos"
_RUN_OUTPUT_FILE = "simulation-output.txt"
_DEFAULT_LIBRARIES = ("Modelica",)
@dataclass(frozen=True)
@@ -374,6 +375,7 @@ class Simulation:
process = self._run_model_commands(
modelica,
[
f"checkModel({model_name})",
f'buildModel({model_name}, outputFormat="csv")',
"getErrorString()",
],
@@ -385,7 +387,7 @@ class Simulation:
if windows_executable.is_file():
executable = windows_executable
else:
details = process.stderr.strip() or process.stdout.strip()
details = process.diagnostics()
suffix = f": {details}" if details else ""
raise OpenModelicaError(
f"OpenModelica did not build {model_name!r}{suffix}"
@@ -691,10 +693,20 @@ def _load_model_script(
working_directory: Path,
commands: Sequence[str],
) -> str:
"""Create a script that loads ``model.mo`` before custom commands."""
"""Create a script that loads default libraries and ``model.mo`` before custom commands."""
load_libraries = []
for library in _DEFAULT_LIBRARIES:
load_libraries.extend([
f"loaded := loadModel({library});",
"if not loaded then",
" print(getErrorString());",
" exit(1);",
"end if;",
])
return "\n".join(
[
f"cd({json.dumps(str(working_directory.resolve()))});",
*load_libraries,
f"loaded := loadFile({json.dumps(_MODEL_FILE)});",
"if not loaded then",
" print(getErrorString());",

File diff suppressed because it is too large Load Diff