Compare commits

..

45 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
0c93338e39 Start of graph view 2026-08-15 17:18:51 +02:00
961a173a17 Fixed signal tree and added progress bar in besim 2026-08-02 18:51:33 +02:00
dfff760cc1 Started bondgraph library 2026-08-02 12:10:08 +02:00
c8373c3423 Resulable besim handof so recompiling model does not give second besim window 2026-08-02 12:05:08 +02:00
4b565edb5c Initial start of the bedit library 2026-08-02 12:02:20 +02:00
feba2c9798 Plot settings and version 2026-08-02 11:54:38 +02:00
51d01ad208 x axis change 2026-08-02 11:41:34 +02:00
e85c507d7f Fixed simulation advancing with play button 2026-08-02 11:30:57 +02:00
756a621ab1 Plotting signals in besim 2026-08-02 11:22:28 +02:00
2128fb00b4 Hooked up simulation to BEsim 2026-08-01 12:25:11 +02:00
a38d461a36 compilation output to logs 2026-07-31 12:52:04 +02:00
22e9a0386c BEsim logger 2026-07-31 12:48:42 +02:00
89ac2d8ff8 Launching of simulation application 2026-07-31 12:24:52 +02:00
66737e323b Start of simulation application 2026-07-31 11:46:06 +02:00
b3aabae5e8 Simulation settings window 2026-07-30 21:27:15 +02:00
a03c6d624e Start of simulation settings window 2026-07-30 21:12:36 +02:00
748bb08531 Equation editor undo 2026-07-29 18:40:50 +02:00
9df352f6b7 eqation editor widget 2026-07-29 18:31:56 +02:00
346a963acc AI'ed clipboard 2026-07-27 16:42:16 +02:00
5df9e53d58 Delete components in tree 2026-07-27 15:55:38 +02:00
51a1537c9c Added new and delete component actions 2026-07-27 15:29:59 +02:00
a4daaa8798 Icon in document tree 2026-07-27 15:15:13 +02:00
54788583a4 Get bounding box of an icon 2026-07-27 14:49:24 +02:00
e5968e165e Line in icon tool 2026-07-27 14:40:48 +02:00
c68e693359 Text fields and saving Icons to file 2026-07-27 14:30:25 +02:00
13d3825f9b Added ports to icon 2026-07-27 14:12:13 +02:00
035cbdf53f Rectangles in incons 2026-07-27 14:03:06 +02:00
09a9e3f12f Start of the icon editor 2026-07-27 13:08:15 +02:00
713d094b08 Parameter editor added 2026-07-27 12:07:47 +02:00
36051e1577 Removed AI generated test suites 2026-07-27 12:07:37 +02:00
8b45674cd2 Hooked up port editor in the document tree 2026-07-26 17:20:37 +02:00
b417d85477 port editor widget ui 2026-07-26 16:57:30 +02:00
d9304ba2e0 Renaming the document root and components from the tree 2026-07-26 16:14:51 +02:00
115 changed files with 20613 additions and 926 deletions

1
.gitignore vendored
View File

@@ -4,7 +4,6 @@ __pycache__/
*.egg-info/ *.egg-info/
build/ build/
dist/ dist/
.pytest_cache/
.vscode/* .vscode/*
!.vscode/launch.json !.vscode/launch.json
!.vscode/tasks.json !.vscode/tasks.json

23
.vscode/launch.json vendored
View File

@@ -5,7 +5,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python Debugger: Module", "name": "BEdit debug",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "bedit_gui", "module": "bedit_gui",
@@ -14,7 +14,26 @@
"env": { "env": {
"QT_QPA_PLATFORMTHEME": "qt6ct", "QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_QPA_PLATFORM": "xcb" "QT_QPA_PLATFORM": "xcb"
} },
"args": [
"-f", "${workspaceFolder}/untitled.bedit.json"
]
},
{
"name": "BEsim debug",
"type": "debugpy",
"request": "launch",
"module": "bedit_gui.simulation_application",
"preLaunchTask": "Qt: Generate files",
"cwd": "${workspaceFolder}",
"env": {
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_QPA_PLATFORM": "xcb"
},
"args": [
"-s", "run_bondgraph",
"${workspaceFolder}/untitled.besim.json"
]
} }
] ]
} }

29
.vscode/tasks.json vendored
View File

@@ -1,31 +1,6 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{
"label": "Tests: Run pytest",
"type": "shell",
"command": "${command:python.interpreterPath}",
"args": [
"-m",
"pytest"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PYTHONPATH": "${workspaceFolder}/src"
}
},
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"clear": true,
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{ {
"label": "Qt: Open Designer", "label": "Qt: Open Designer",
"type": "shell", "type": "shell",
@@ -66,7 +41,7 @@
"reveal": "always", "reveal": "always",
"panel": "dedicated" "panel": "dedicated"
}, },
"problemMatcher": [] "problemMatcher": [],
} }
] ]
} }

237
AGENTS.md Normal file
View File

@@ -0,0 +1,237 @@
# BEdit Agent Guide
This file is the handoff context for coding agents working in this repository. Read it before making changes.
## Working agreement
- Make only the changes the user requested.
- Keep completely out of unrelated code. Do not reformat, reorder, rename, clean up, or “improve” code that is outside the task.
- Preserve existing user changes and assume a dirty worktree belongs to the user.
- Inspect the relevant files before deciding on an implementation.
- Prefer small, modular changes over growing `MainWindow`, a controller, or another file into a monolith.
- When the user asks why something happens, diagnose and explain it without editing files unless they also ask for a fix.
- Do not create commits unless explicitly requested.
## Code style
Follow the local style in the file being edited, with these user preferences taking priority:
- Prefer compact, readable one-line imports. Do not introduce parenthesized multiline imports.
- Avoid spreading short function calls, conditions, and expressions across multiple lines.
- Do not run broad formatters or import sorters.
- Do not use a lint autofix over the project.
- Keep classes and functions focused. Extract a controller, service, command, model, or reusable widget when a feature would otherwise make an existing file large.
- Generated modules contain no handwritten application behavior.
The project uses Ruff, but import sorting is intentionally not enforced during scoped checks:
```sh
.venv/bin/ruff check --ignore I001 path/to/changed_file.py
```
## Project structure and dependency direction
Relevant GUI structure:
```text
src/bedit_gui/
├── application.py
├── commands/
├── controllers/
├── documents/
├── models.py
├── services/
├── ui/
│ ├── forms/
│ └── generated/
├── resources/
│ └── generated/
├── utils/
└── views/
└── models/
```
Responsibilities:
- `application.py`: composition root. Create and connect the application, document, window, services, and controllers here.
- `documents/document.py`: editable document facade. Owns the core model, path, main `QUndoStack`, modified state, and high-level operations.
- `commands/`: `QUndoCommand` implementations. Persistent changes to the document model go through commands.
- `controllers/`: connect actions and widgets to document/service operations. Keep workflow logic out of `MainWindow`.
- `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, including icon, graph, and simulation databases.
- `bedit_core`: domain model and serialization. It must never import from `bedit_gui`.
Preferred direction:
```text
views/controllers
documents/services
commands
bedit_core
```
Avoid introducing imports from `bedit_gui` into `bedit_core` or circular dependencies between services and documents.
## Qt Designer and generated files
Raw forms are in:
```text
src/bedit_gui/ui/forms/
```
Generated Python is in:
```text
src/bedit_gui/ui/generated/
```
Never add handwritten behavior to generated UI modules. Change the `.ui` form and regenerate with:
```sh
.venv/bin/python scripts/generate_qt_files.py
```
The generation script also fixes the package-qualified resource import. Calling `pyside6-uic` directly without the script can produce a broken `resources_rc` import.
Raw resources and the QRC file live under `src/bedit_gui/resources/`. Generated resource Python lives under `src/bedit_gui/resources/generated/`.
## Undo and document changes
- The main application document owns the main undo stack.
- The icon editor owns a separate local undo stack.
- The `Document` facade should expose high-level methods that push commands. Controllers should normally call those methods rather than construct commands.
- Commands mutate the model in `redo()`/`undo()` and emit the appropriate document signals.
- Multi-object user operations should be one command or one undo macro.
- Allocate stable IDs and final names before pushing a command so redo reproduces the same result.
- Component names must be unique within their destination component dictionary. Conflicts use `_0`, `_1`, and so on.
- Copy/paste must generate new component, port, parameter, connection, and icon-shape IDs and rewrite references.
## Document tree
`DocumentTreeModel` has two columns:
- Column 0: editable document/component name.
- Column 1: rendered component icon.
Use `selectedRows(0)` for multi-selection; `selectedIndexes()` returns both columns. Filter selected descendants when an ancestor is also selected.
The tree uses extended row selection. Delete, cut, and copy may operate on multiple components. Paste targets either:
- the document root, or
- the component dictionary of a selected graph component.
The controller listens to document model/icon signals and refreshes the tree/icon cache.
## Clipboard architecture
Clipboard support is intentionally extensible:
- `services/clipboard.py`: system `QClipboard` and MIME/JSON handling.
- `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 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"]`.
- Shapes currently include rectangles, text, and lines.
- Shape changes use the icon editors local undo stack.
- Shapes are selectable, movable, resizable, pixel-snapped, and constrained to the icon scene.
- Ports are separate 16×16 items: black for inputs and white for outputs. They can be moved but are not ordinary deletable/copyable shapes.
- Colors are serialized as `#rrggbbaa`.
- The icon scene is currently `(-64, -64, 128, 128)`.
- The grid spacing is 8 scene pixels and is drawn only inside the scene rectangle.
- 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.
- An action that only exists as a child object may not have an active shortcut; attach it to the relevant widget or place it in a menu/toolbar.
- Route application-wide Copy/Cut/Paste by focused widget through `ClipboardController`.
- 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.
- Keep compilation GUI-independent. Shared compile entry points belong in `bedit_simulation` and must not create or depend on a `QApplication` or window.
- The simulator opens BEdit `.beb`/`.json` documents or serialized simulation `.bes`/`.json` files.
- A component selector uses default simulation settings. A stored simulation-settings block already identifies its component; do not require both selectors.
- `SimulationDatabase.active_simulation` is the persisted active settings ID selected when the Simulation Settings dialog is accepted.
- Simulation files serialize `bedit_gui.simulation_models.SimulationRoot`. JSON uses the `root_type = "simulation_root"` discriminator; binary `.bes` files use their own BES header.
- `SimulationRoot` contains source-document identity, the selected component and copied settings, and eventually results. Compiled artifacts are transient application state and must not be serialized; reopen saved simulations by compiling them again. Never serialize live Python objects.
- 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
The old test suite and its VS Code/packaging references were deliberately removed. Do not recreate a test suite unless asked.
Use checks proportional to the change:
```sh
.venv/bin/ruff check --ignore I001 path/to/changed_files.py
```
For Qt smoke checks in a headless environment:
```sh
QT_QPA_PLATFORM=minimal QT_QPA_PLATFORMTHEME= QT_STYLE_OVERRIDE=Fusion .venv/bin/python ...
```
Prefer focused model, serialization, signal, command undo/redo, and offscreen rendering checks. Do not launch a GUI during verification unless explicitly requested or approved.
## Handoff expectations
At the end of a task, report:
- the outcome,
- the files or subsystem changed,
- relevant behavior and limitations,
- checks that actually passed.
Do not claim tests passed when only a lint or smoke check was run.

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
]
}
}

BIN
lib/bondgraph.beb Normal file

Binary file not shown.

BIN
lib/signal.beb Normal file

Binary file not shown.

BIN
lib/signal_sources.beb Normal file

Binary file not shown.

View File

@@ -18,20 +18,12 @@ dependencies = [
bedit-graphviz = "bedit_util.graphviz:main" bedit-graphviz = "bedit_util.graphviz:main"
bedit-simulate = "bedit_util.simulate:main" bedit-simulate = "bedit_util.simulate:main"
bedit = "bedit_gui.application:main" bedit = "bedit_gui.application:main"
besim = "bedit_gui.simulation_application:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8",
"ruff>=0.5", "ruff>=0.5",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.pytest.ini_options]
addopts = "--strict-config --strict-markers -ra"
testpaths = ["tests"]
markers = [
"unit: fast tests without GUI event-loop interaction",
"gui: tests that create or interact with Qt objects",
]

View File

@@ -83,6 +83,7 @@ class _CausalityEngine():
def clear_causalities(self) -> None: def clear_causalities(self) -> None:
for bond in self._network.bonds: for bond in self._network.bonds:
bond.connection.causality = BondCausality.NONE bond.connection.causality = BondCausality.NONE
bond.connection.undesired = False
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None: def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
attached_connections = self._network.bonds_for(port) attached_connections = self._network.bonds_for(port)
@@ -106,7 +107,7 @@ class _CausalityEngine():
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None: def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
# Get neighor # Get neighor
is_source = (component == connection.source.component) 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 # 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]: if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
self.evaluate_junction_constraints(neighbor) self.evaluate_junction_constraints(neighbor)

View File

@@ -95,7 +95,7 @@ class BondPort(Port):
@dataclass @dataclass
class Parameter: class Parameter:
name: str name: str
value: Any = 1.0 value: Any = "1.0"
value_type: ValueType = ValueType.REAL value_type: ValueType = ValueType.REAL
matrix_size: Annotated[list[int], 2] = field(default_factory=lambda: [1, 1]) matrix_size: Annotated[list[int], 2] = field(default_factory=lambda: [1, 1])
quantity: str | None = None quantity: str | None = None

View File

@@ -1,38 +1,77 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import argparse
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication, QMessageBox
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, GraphEditorClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.document_controller import DocumentController from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController 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.settings_controller import SettingsController
from bedit_gui.controllers.simulation_settings_controller import SimulationSettingsController
from bedit_gui.controllers.simulation_controller import SimulationController
from bedit_gui.controllers.undo_controller import UndoController from bedit_gui.controllers.undo_controller import UndoController
from bedit_gui.controllers.view_menu_controller import ViewMenuController from bedit_gui.controllers.view_menu_controller import ViewMenuController
from bedit_gui.controllers.window_state_controller import WindowStateController from bedit_gui.controllers.window_state_controller import WindowStateController
from bedit_gui.controllers.document_tree_controller import DocumentTreeController from bedit_gui.controllers.document_tree_controller import DocumentTreeController
from bedit_gui.documents import Document from bedit_gui.documents import Document
from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.services.clipboard import ClipboardService
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
from bedit_gui.versions import BEDIT_VERSION
def parse_arguments():
parser = argparse.ArgumentParser(exit_on_error=False)
parser.add_argument(
"-f", "--file", type=str, help="Path to a file to open", default=None, required=False
)
parser.add_argument("--version", action="version", version=f"BEdit {BEDIT_VERSION}")
return parser.parse_args()
def main() -> int: def main() -> int:
try:
args = parse_arguments()
except argparse.ArgumentError as e:
print(e.message)
return 1
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setOrganizationName("BEdit") app.setOrganizationName("BEdit")
app.setApplicationName("BEdit") app.setApplicationName("BEdit")
app.setApplicationVersion(BEDIT_VERSION)
settings = ApplicationSettings() settings = ApplicationSettings()
document = Document(app) 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) LogController(window, settings.log_level)
DocumentController(document, window) DocumentController(document, window)
SettingsController(window, settings) settings_controller = SettingsController(window, settings)
SimulationSettingsController(document, window)
SimulationController(document, window)
UndoController(document, window) UndoController(document, window)
ViewMenuController(window) ViewMenuController(window)
DocumentTreeController(document, 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), GraphEditorClipboardHandler(document, window.graph_editor, clipboard)])
window_state_controller = WindowStateController(app, window) window_state_controller = WindowStateController(app, window)
window_state_controller.restore() window_state_controller.restore()
if args.file:
document.open(args.file)
else:
document.new()
window.showMaximized() window.showMaximized()
return app.exec() return app.exec()

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

@@ -0,0 +1,23 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import ComponentID
from bedit_gui.models import Icon
class ChangeIconCommand(QUndoCommand):
def __init__(self, document: object, component_id: ComponentID, icon: Icon) -> None:
super().__init__("Change icon")
self.document = document
self.component_id = component_id
self.old_icon = document.stored_component_icon(component_id)
self.new_icon = deepcopy(icon)
def redo(self) -> None:
self.document._set_component_icon(self.component_id, self.new_icon)
def undo(self) -> None:
self.document._set_component_icon(self.component_id, self.old_icon)

View File

@@ -0,0 +1,178 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
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 | dict[ComponentID, Component]) -> None:
super().__init__("Add graph component")
self.document = document
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()))
def redo(self) -> None:
self.components[self.component_id] = self.component
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
del self.components[self.component_id]
self.document.model_changed.emit(self.document.model)
class AddEmptyEquationComponent(QUndoCommand):
def __init__(self, document: object, parent: Component | dict[ComponentID, Component]) -> None:
super().__init__("Add equation component")
self.document = document
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.components[self.component_id] = self.component
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
del self.components[self.component_id]
self.document.model_changed.emit(self.document.model)
class DeleteComponent(QUndoCommand):
def __init__(self, document: object, component: Component) -> None:
super().__init__("Delete component")
self.document = document
self.component = component
self.components, self.component_id, self.parent_graph = self._find_component(document.model.root, component)
self.component_index = list(self.components).index(self.component_id)
port_ids = set(component.interface.ports)
self.connections: list[tuple[int, ConnectionID, Connection]] = []
if self.parent_graph is not None:
for index, (connection_id, connection) in enumerate(self.parent_graph.connections.items()):
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:
for _, connection_id, _ in self.connections:
self.parent_graph.connections.pop(connection_id, None)
self.components.pop(self.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)
def undo(self) -> None:
self._restore_item(self.components, self.component_id, self.component, self.component_index)
if self.parent_graph is not None:
for index, connection_id, connection in self.connections:
self._restore_item(self.parent_graph.connections, connection_id, connection, index)
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]:
for component_id, component in components.items():
if component is target:
return components, component_id, parent_graph
if isinstance(component.implementation, GraphImplementation):
try:
return cls._find_component(component.implementation.graph.components, target, component.implementation.graph)
except ValueError:
pass
raise ValueError("component is not part of this document")
@classmethod
def _component_ids(cls, component_id: ComponentID, component: Component) -> list[ComponentID]:
component_ids = [component_id]
if isinstance(component.implementation, GraphImplementation):
for child_id, child in component.implementation.graph.components.items():
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())
values.insert(index, (item_id, item))
items.clear()
items.update(values)
class PasteComponents(QUndoCommand):
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None, 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,53 @@
from __future__ import annotations
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, EquationImplementation
class ChangeEquationTextCommand(QUndoCommand):
COMMAND_ID = 1001
def __init__(self, document: object, component: Component, section: str, text: list[str], edit_id: int) -> None:
super().__init__(self._command_text(section))
implementation = component.implementation
if not isinstance(implementation, EquationImplementation):
raise TypeError("equation text can only be changed on an equation component")
self.document = document
self.component = component
self.section = section
self.old_text = list(getattr(implementation, section))
self.new_text = list(text)
self.edit_id = edit_id
def id(self) -> int:
return self.COMMAND_ID
def mergeWith(self, other: QUndoCommand) -> bool:
if not isinstance(other, ChangeEquationTextCommand):
return False
if other.component is not self.component or other.section != self.section or other.edit_id != self.edit_id:
return False
self.new_text = list(other.new_text)
return True
def redo(self) -> None:
self._set_text(self.new_text)
def undo(self) -> None:
self._set_text(self.old_text)
def _set_text(self, text: list[str]) -> None:
implementation = self.component.implementation
assert isinstance(implementation, EquationImplementation)
setattr(implementation, self.section, list(text))
self.document.equation_text_changed.emit(self.component, self.section)
@staticmethod
def _command_text(section: str) -> str:
return {
"declarations": "Edit declarations",
"initial_equations": "Edit initial equations",
"equations": "Edit equations",
}[section]

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,76 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, Parameter, ParameterID
class AddParamCommand(QUndoCommand):
def __init__(
self,
document: object,
component: Component,
param_id: ParameterID,
param: Parameter,
) -> None:
super().__init__("Add port")
self.document = document
self.component = component
self.param_id = param_id
self.param = deepcopy(param)
def redo(self) -> None:
self.component.parameters[self.param_id] = deepcopy(self.param)
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
del self.component.parameters[self.param_id]
self.document.model_changed.emit(self.document.model)
class RemoveParamCommand(QUndoCommand):
def __init__(
self,
document: object,
component: Component,
param_id: ParameterID,
) -> None:
super().__init__("Remove parameter")
self.document = document
self.component = component
self.param_id = param_id
self.param = deepcopy(component.parameters[param_id])
def redo(self) -> None:
del self.component.parameters[self.param_id]
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.component.parameters[self.param_id] = deepcopy(self.param)
self.document.model_changed.emit(self.document.model)
class ChangeParamCommand(QUndoCommand):
def __init__(
self,
document: object,
component: Component,
param_id: ParameterID,
param: Parameter,
) -> None:
super().__init__("Change parameter")
self.document = document
self.component = component
self.param_id = param_id
self.old_param = deepcopy(component.parameters[param_id])
self.new_param = deepcopy(param)
def redo(self) -> None:
self.component.parameters[self.param_id] = deepcopy(self.new_param)
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.component.parameters[self.param_id] = deepcopy(self.old_param)
self.document.model_changed.emit(self.document.model)

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, Port, PortID
class AddPortCommand(QUndoCommand):
def __init__(
self,
document: object,
component: Component,
port_id: PortID,
port: Port,
) -> None:
super().__init__("Add port")
self.document = document
self.component = component
self.port_id = port_id
self.port = deepcopy(port)
def redo(self) -> None:
self.component.interface.ports[self.port_id] = deepcopy(self.port)
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
del self.component.interface.ports[self.port_id]
self.document.model_changed.emit(self.document.model)
class RemovePortCommand(QUndoCommand):
def __init__(
self,
document: object,
component: Component,
port_id: PortID,
) -> None:
super().__init__("Remove port")
self.document = document
self.component = component
self.port_id = port_id
self.port = deepcopy(component.interface.ports[port_id])
def redo(self) -> None:
del self.component.interface.ports[self.port_id]
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.component.interface.ports[self.port_id] = deepcopy(self.port)
self.document.model_changed.emit(self.document.model)
class ChangePortCommand(QUndoCommand):
def __init__(
self,
document: object,
component: Component,
port_id: PortID,
port: Port,
) -> None:
super().__init__("Change port")
self.document = document
self.component = component
self.port_id = port_id
self.old_port = deepcopy(component.interface.ports[port_id])
self.new_port = deepcopy(port)
def redo(self) -> None:
self.component.interface.ports[self.port_id] = deepcopy(self.new_port)
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.component.interface.ports[self.port_id] = deepcopy(self.old_port)
self.document.model_changed.emit(self.document.model)

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

@@ -0,0 +1,19 @@
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component
class RenameComponentCommand(QUndoCommand):
def __init__(self, document, component: Component, new_name: str) -> None:
super().__init__("Rename component")
self.document = document
self.component = component
self.old_name = component.name
self.new_name = new_name
def redo(self) -> None:
self.component.name = self.new_name
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.component.name = self.old_name
self.document.model_changed.emit(self.document.model)

View File

@@ -0,0 +1,18 @@
from PySide6.QtGui import QUndoCommand
class RenameDocumentCommand(QUndoCommand):
def __init__(self, document, new_name: str) -> None:
super().__init__("Rename document")
self.document = document
self.old_name = document.model.name
self.new_name = new_name
def redo(self) -> None:
self.document.model.name = self.new_name
self.document.model_changed.emit(self.document.model)
def undo(self) -> None:
self.document.model.name = self.old_name
self.document.model_changed.emit(self.document.model)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_gui.models import SimulationDatabase
class ChangeSimulationDatabaseCommand(QUndoCommand):
def __init__(self, document: object, database: SimulationDatabase) -> None:
super().__init__("Edit simulation settings")
self.document = document
self.old_database = document.stored_simulation_database()
self.new_database = deepcopy(database)
def redo(self) -> None:
self.document._set_simulation_database(self.new_database)
def undo(self) -> None:
self.document._set_simulation_database(self.old_database)

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from collections.abc import Callable
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_gui.simulation_models import SimulationPlotSettings, SimulationPlotTab, SimulationRoot
ChangedCallback = Callable[[int], None]
class AddSimulationPlotTabCommand(QUndoCommand):
def __init__(self, root: SimulationRoot, tab: SimulationPlotTab, index: int, changed: ChangedCallback) -> None:
super().__init__("Add plot")
self.root = root
self.tab = tab
self.index = index
self.changed = changed
def redo(self) -> None:
self.root.plot_tabs.insert(self.index, self.tab)
self.changed(self.index)
def undo(self) -> None:
self.root.plot_tabs.pop(self.index)
self.changed(max(0, self.index - 1))
class RemoveSimulationPlotTabCommand(QUndoCommand):
def __init__(self, root: SimulationRoot, index: int, changed: ChangedCallback) -> None:
super().__init__("Remove plot")
self.root = root
self.index = index
self.tab = root.plot_tabs[index]
self.changed = changed
def redo(self) -> None:
self.root.plot_tabs.pop(self.index)
self.changed(min(self.index, len(self.root.plot_tabs) - 1))
def undo(self) -> None:
self.root.plot_tabs.insert(self.index, self.tab)
self.changed(self.index)
class RenameSimulationPlotTabCommand(QUndoCommand):
def __init__(self, root: SimulationRoot, index: int, name: str, changed: ChangedCallback) -> None:
super().__init__("Rename plot")
self.root = root
self.index = index
self.old_name = root.plot_tabs[index].name
self.name = name
self.changed = changed
def redo(self) -> None:
self.root.plot_tabs[self.index].name = self.name
self.changed(self.index)
def undo(self) -> None:
self.root.plot_tabs[self.index].name = self.old_name
self.changed(self.index)
class ChangeSimulationPlotSignalsCommand(QUndoCommand):
def __init__(self, root: SimulationRoot, index: int, signals: list[str], changed: ChangedCallback) -> None:
super().__init__("Change plotted signals")
self.root = root
self.index = index
self.old_signals = list(root.plot_tabs[index].signals)
self.signals = list(signals)
self.changed = changed
def redo(self) -> None:
self.root.plot_tabs[self.index].signals = list(self.signals)
self.changed(self.index)
def undo(self) -> None:
self.root.plot_tabs[self.index].signals = list(self.old_signals)
self.changed(self.index)
class ChangeSimulationPlotXAxisCommand(QUndoCommand):
def __init__(self, root: SimulationRoot, index: int, x_axis: str | None, changed: ChangedCallback) -> None:
super().__init__("Change plot x axis")
self.root = root
self.index = index
self.old_x_axis = root.plot_tabs[index].x_axis
self.x_axis = x_axis
self.changed = changed
def redo(self) -> None:
self.root.plot_tabs[self.index].x_axis = self.x_axis
self.changed(self.index)
def undo(self) -> None:
self.root.plot_tabs[self.index].x_axis = self.old_x_axis
self.changed(self.index)
class ChangeSimulationPlotSettingsCommand(QUndoCommand):
def __init__(self, root: SimulationRoot, index: int, settings: SimulationPlotSettings, changed: ChangedCallback) -> None:
super().__init__("Change plot settings")
self.root = root
self.index = index
self.old_settings = deepcopy(root.plot_tabs[index].settings)
self.settings = deepcopy(settings)
self.changed = changed
def redo(self) -> None:
self.root.plot_tabs[self.index].settings = deepcopy(self.settings)
self.changed(self.index)
def undo(self) -> None:
self.root.plot_tabs[self.index].settings = deepcopy(self.old_settings)
self.changed(self.index)

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
from PySide6.QtCore import QEvent, QObject, QTimer, Signal
from PySide6.QtWidgets import QApplication, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QTextEdit, QTreeView, QWidget
from bedit_core.models import Component, GraphImplementation
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
class ClipboardHandler(QObject):
"""Base implementation for adding clipboard support to another editor."""
availability_changed = Signal()
def owns_focus(self, _widget: QWidget) -> bool:
return False
def can_copy(self) -> bool:
return False
def can_cut(self) -> bool:
return False
def can_paste(self) -> bool:
return False
def can_delete(self) -> bool:
return False
def copy(self) -> None:
pass
def cut(self) -> None:
pass
def paste(self) -> None:
pass
def delete(self) -> None:
pass
class TextClipboardHandler(ClipboardHandler):
def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
super().__init__(parent)
self.clipboard = clipboard
def owns_focus(self, widget: QWidget) -> bool:
return isinstance(widget, (QLineEdit, QTextEdit, QPlainTextEdit))
def can_copy(self) -> bool:
return self._has_selection()
def can_cut(self) -> bool:
widget = self._widget()
return widget is not None and not widget.isReadOnly() and self._has_selection()
def can_paste(self) -> bool:
widget = self._widget()
return widget is not None and not widget.isReadOnly() and self.clipboard.has_text()
def copy(self) -> None:
widget = self._widget()
if widget is not None:
widget.copy()
def cut(self) -> None:
widget = self._widget()
if widget is not None and not widget.isReadOnly():
widget.cut()
def paste(self) -> None:
widget = self._widget()
if widget is not None and not widget.isReadOnly():
widget.paste()
def _has_selection(self) -> bool:
widget = self._widget()
if isinstance(widget, QLineEdit):
return widget.hasSelectedText()
return widget.textCursor().hasSelection() if widget is not None else False
@staticmethod
def _widget() -> QLineEdit | QTextEdit | QPlainTextEdit | None:
widget = QApplication.focusWidget()
return widget if isinstance(widget, (QLineEdit, QTextEdit, QPlainTextEdit)) else None
class DocumentTreeClipboardHandler(ClipboardHandler):
def __init__(self, document: Document, tree: QTreeView, model: DocumentTreeModel, clipboard: ClipboardService, parent: QObject | None = None) -> None:
super().__init__(parent)
self.document = document
self.tree = tree
self.model = model
self.clipboard = clipboard
self.tree.selectionModel().selectionChanged.connect(self.availability_changed)
self.tree.selectionModel().currentChanged.connect(self.availability_changed)
def owns_focus(self, widget: QWidget) -> bool:
return widget is self.tree or self.tree.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())
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:
target = self._target()
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
if target is None or payload is None:
return
try:
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, 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)
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
def _selected_components(self) -> list[Component]:
indexes = self.tree.selectionModel().selectedRows(0)
selected = {id(component) for index in indexes if isinstance(component := self.model.value(index), Component)}
components: list[Component] = []
for index in indexes:
component = self.model.value(index)
if not isinstance(component, Component):
continue
parent = index.parent()
nested = False
while parent.isValid():
value = self.model.value(parent)
if isinstance(value, Component) and id(value) in selected:
nested = True
break
parent = parent.parent()
if not nested:
components.append(component)
return components
def _target(self) -> dict | None:
value = self.model.value(self.tree.currentIndex())
if isinstance(value, CoreDocument):
return value.root
if isinstance(value, Component) and isinstance(value.implementation, GraphImplementation):
return value.implementation.graph.components
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)
self.window = window
self.clipboard = clipboard
self.handlers = handlers
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)
clipboard.changed.connect(self.update_actions)
for handler in handlers:
handler.setParent(self)
handler.availability_changed.connect(self.update_actions)
self.update_actions()
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if event.type() in (QEvent.Type.KeyRelease, QEvent.Type.MouseButtonRelease):
QTimer.singleShot(0, self.update_actions)
return super().eventFilter(watched, event)
def active_handler(self) -> ClipboardHandler | None:
widget = QApplication.focusWidget()
if widget is None:
return None
return next((handler for handler in self.handlers if handler.owns_focus(widget)), None)
def copy(self) -> None:
handler = self.active_handler()
if handler is not None and handler.can_copy():
handler.copy()
self.update_actions()
def cut(self) -> None:
handler = self.active_handler()
if handler is not None and handler.can_cut():
handler.cut()
self.update_actions()
def paste(self) -> None:
handler = self.active_handler()
if handler is not None and handler.can_paste():
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

@@ -1,9 +1,43 @@
from PySide6.QtCore import QObject from collections.abc import Callable
from functools import partial
from typing import Protocol
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, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document from bedit_gui.documents import Document
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.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel from bedit_gui.views.models.document_tree_model import DocumentTreeModel
from bedit_gui.utils.icon import render_fitted_icon
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: ...
def params(self) -> dict[ParameterID, Parameter]: ...
InterfaceEditorFactory = Callable[
[dict[PortID, Port], dict[PortID, PortMetadata], MainWindow],
InterfaceEditorLike,
]
ParamEditorFactory = Callable[
[dict[ParameterID, Parameter], MainWindow],
ParamEditorLike
]
class DocumentTreeController(QObject): class DocumentTreeController(QObject):
@@ -11,24 +45,278 @@ class DocumentTreeController(QObject):
self, self,
document: Document, document: Document,
window: MainWindow, window: MainWindow,
interface_editor_factory: InterfaceEditorFactory = InterfaceEditorDialog,
param_editor_factory: ParamEditorFactory = ParamEditorDialog,
) -> None: ) -> None:
super().__init__(window) super().__init__(window)
self.document = document self.document = document
self.window = window self.window = window
self.model = DocumentTreeModel() self.model = DocumentTreeModel()
self.interface_editor_factory = interface_editor_factory
self.param_editor_factory = param_editor_factory
self._icon_editors: list[IconEditorWindow] = []
self._components: dict[ComponentID, Component] = {}
window.ui.documentTree.setModel(self.model) 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.model_changed.connect(self._on_document_changed)
document.icon_changed.connect(self._on_icon_changed)
document.port_metadata_database_changed.connect(self._on_port_metadata_database_changed)
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
document.graph_component_label_changed.connect(self._on_graph_component_label_changed)
document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed)
document.equation_text_changed.connect(self._on_equation_text_changed)
self.model.rename_document_requested.connect(self.document.rename)
self.model.rename_component_requested.connect(self.document.rename_component)
window.graph_editor.component_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)
window.ui.documentTree.addAction(window.ui.actionEscape)
window.ui.actionEscape.triggered.connect(self.deselect)
window.ui.documentTree.setHeaderHidden(True) window.ui.documentTree.setHeaderHidden(True)
window.ui.documentTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
window.ui.documentTree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
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, 28)
self._tree_viewport = window.ui.documentTree.viewport()
self._tree_viewport.installEventFilter(self)
self._on_document_changed(document.model) self._on_document_changed(document.model)
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self._tree_viewport and event.type() in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease):
assert isinstance(event, QMouseEvent)
if event.button() == Qt.MouseButton.RightButton:
if event.type() == QEvent.Type.MouseButtonRelease:
self._show_context_menu(event.position().toPoint())
return True
return super().eventFilter(watched, event)
def _on_document_changed(self, model: CoreDocument) -> None: def _on_document_changed(self, model: CoreDocument) -> None:
"""Rebuild the tree whenever New/Open replaces the core document.""" """Rebuild the tree whenever New/Open replaces the core document."""
displayed_component = self.window.graph_editor.component() or self.window.equation_editor.component()
self.model.set_document(model) 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_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 # Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes. # expanded component IDs and restore only those nodes.
self.window.ui.documentTree.expandAll() self.window.ui.documentTree.expandAll()
def _selection_changed(self, *_args: object) -> None:
indexes = self.window.ui.documentTree.selectionModel().selectedRows(0)
component = self.model.value(indexes[0]) if len(indexes) == 1 else None
self._show_component(component if isinstance(component, Component) else None)
def _show_component(self, component: Component | None) -> None:
if component is not None and isinstance(component.implementation, EquationImplementation):
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):
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)
self.window.graph_editor.hide()
def _on_equation_text_changed(self, component: Component, section: str) -> None:
if self.window.equation_editor.component() is component:
self.window.equation_editor.refresh_text(section)
def _on_icon_changed(self, component_id: ComponentID, icon: object) -> None:
component = self._components.get(component_id)
if component is None:
return
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():
self._components[component_id] = component
if isinstance(component.implementation, GraphImplementation):
self._collect_components(component.implementation.graph.components)
def _show_context_menu(self, position: QPoint) -> None:
index = self.window.ui.documentTree.indexAt(position)
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 = 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(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 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 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)
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(), dialog.port_metadata())
def _edit_params(self, component: Component) -> None:
dialog = self.param_editor_factory(
component.parameters,
self.window
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.document.update_component_params(component, dialog.params())
def _edit_icon(self, component: Component) -> None:
component_id = self.document.component_id(component)
editor = IconEditorWindow(self.document.component_icon(component_id), component.interface.ports, self.window)
editor.saved.connect(partial(self.document.change_icon, component_id))
editor.destroyed.connect(partial(self._icon_editor_closed, editor))
self._icon_editors.append(editor)
editor.show()
def _icon_editor_closed(self, editor: IconEditorWindow, *_args: object) -> None:
if editor in self._icon_editors:
self._icon_editors.remove(editor)
def _add_graph_component(self, component: Component) -> None:
self.document.add_empty_graph_component(component)
def _add_equation_component(self, component: Component) -> None:
self.document.add_empty_equation_component(component)
def deselect(self) -> None:
self.window.ui.documentTree.selectionModel().clear()
def delete_selected_component(self) -> None:
focused = self.window.ui.documentTree.hasFocus()
if focused:
self.document.delete_components(self._selected_components())
def _delete_component(self, component: Component) -> None:
self.document.delete_component(component)
def _selected_components(self) -> list[Component]:
indexes = self.window.ui.documentTree.selectionModel().selectedRows(0)
selected = {id(component) for index in indexes if isinstance(component := self.model.value(index), Component)}
components: list[Component] = []
for index in indexes:
component = self.model.value(index)
if not isinstance(component, Component):
continue
parent = index.parent()
nested = False
while parent.isValid():
value = self.model.value(parent)
if isinstance(value, Component) and id(value) in selected:
nested = True
break
parent = parent.parent()
if not nested:
components.append(component)
return components

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,9 +3,11 @@ from __future__ import annotations
import logging import logging
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QAbstractItemView
from bedit_gui.services.application_logging import configure_logging from bedit_gui.services.application_logging import configure_logging
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.simulation_window import SimulationWindow
from bedit_gui.views.models import LogListModel from bedit_gui.views.models import LogListModel
@@ -30,7 +32,7 @@ class LogController(QObject):
def __init__( def __init__(
self, self,
window: MainWindow, window: MainWindow | SimulationWindow,
level: int | str = logging.INFO, level: int | str = logging.INFO,
) -> None: ) -> None:
super().__init__(window) super().__init__(window)
@@ -48,4 +50,6 @@ class LogController(QObject):
self.emitter.message.connect(self.model.append) self.emitter.message.connect(self.model.append)
self.model.rowsInserted.connect(window.ui.listView.scrollToBottom) self.model.rowsInserted.connect(window.ui.listView.scrollToBottom)
window.ui.listView.setModel(self.model) window.ui.listView.setModel(self.model)
window.ui.listView.setWordWrap(True)
window.ui.listView.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
configure_logging(self.handler, level) configure_logging(self.handler, level)

View File

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

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from PySide6.QtCore import QObject, QProcess, Qt
from PySide6.QtWidgets import QApplication, QMessageBox
from bedit_core.models import Component
from bedit_gui.documents import Document
from bedit_gui.services import simulation_files
from bedit_gui.services.application_logging import get_logger
from bedit_gui.services.simulation_loader import component_choices
from bedit_gui.services.simulation_handoff import send_simulation_handoff
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
from bedit_gui.views.main_window import MainWindow
from bedit_simulation import ModelBuildResult, compile_component_sync
logger = get_logger(__name__)
Compiler = Callable[[Component, str | Path], ModelBuildResult]
Launcher = Callable[[Path, CompiledModel], bool]
def _launch_simulator(path: Path, compiled_model: CompiledModel) -> bool:
if send_simulation_handoff(path, compiled_model):
return True
arguments = ["-m", "bedit_gui.simulation_application", "--handoff", "--model-name", compiled_model.model_name, "--executable", compiled_model.executable, "--working-directory", compiled_model.working_directory, str(path)]
launched = QProcess.startDetached(sys.executable, arguments)
return launched[0] if isinstance(launched, tuple) else bool(launched)
class SimulationController(QObject):
"""Compile the active BEdit simulation and launch the simulator process."""
def __init__(self, document: Document, window: MainWindow, compiler: Compiler = compile_component_sync, launcher: Launcher = _launch_simulator) -> None:
super().__init__(window)
self.document = document
self.window = window
self.compiler = compiler
self.launcher = launcher
self.compiled_root: SimulationRoot | None = None
self.compiled_model: CompiledModel | None = None
window.ui.actionCompile_Model.triggered.connect(self.compile_model)
window.ui.actionOpen_Simulation_Window.triggered.connect(self.open_simulation)
def compile_model(self) -> SimulationRoot | None:
database = self.document.simulation_database()
settings = database.simulations.get(database.active_simulation) if database.active_simulation is not None else None
if settings is None:
QMessageBox.warning(self.window, "No active simulation", "Select and accept a simulation settings block first.")
return None
match = next((choice for choice in component_choices(self.document.model) if choice[0] == settings.component), None)
if match is None:
QMessageBox.critical(self.window, "Could not compile", "The active simulation references a missing component.")
return None
component_id, component, component_path = match
build_directory = Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
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.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
finally:
QApplication.restoreOverrideCursor()
self.compiled_model = CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors)
self.compiled_root = SimulationRoot(
format_version=1,
source_document=str(self.document.path.resolve()) if self.document.path is not None else None,
source_document_id=str(self.document.model.id),
component=component_id,
component_path=component_path,
settings_name=settings.name,
settings=settings,
)
self.window.statusBar().showMessage(f"Compiled {component_path}")
logger.info("Compiled model %s: %s", build.model_name, self.compiled_model.executable)
if build.output.strip():
logger.info("Compiler output:\n%s", build.output.strip())
if build.errors.strip():
logger.warning("Compiler errors:\n%s", build.errors.strip())
return self.compiled_root
def open_simulation(self) -> None:
root = self.compile_model()
if root is None or self.compiled_model is None:
return
transfer_directory = Path(tempfile.mkdtemp(prefix="bedit-simulator-launch-"))
transfer_path = transfer_directory / "simulation.bes"
try:
simulation_files.save(root, transfer_path)
if not self.launcher(transfer_path, self.compiled_model):
raise RuntimeError("the simulator process could not be started")
except (OSError, RuntimeError, ValueError) as exc:
QMessageBox.critical(self.window, "Could not open simulator", str(exc))

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from PySide6.QtCore import QObject, Signal, Qt
from PySide6.QtWidgets import QApplication, QDialog, QFileDialog, QInputDialog, QMessageBox
from bedit_gui.services import document_files, simulation_files
from bedit_gui.services.simulation_loader import compile_simulation_root, component_choices, load_and_compile_bedit
from bedit_gui.models import SimulationDatabase, SimulationID
from bedit_gui.services.application_logging import get_logger
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog
from bedit_gui.views.simulation_window import SimulationWindow
logger = get_logger(__name__)
class SimulationFileController(QObject):
runtime_changed = Signal()
def __init__(self, window: SimulationWindow) -> None:
super().__init__(window)
self.window = window
self.root: SimulationRoot | None = None
self.compiled_model: CompiledModel | None = None
self.path: Path | None = None
window.ui.actionNew_Simulation_Run.triggered.connect(self.new)
window.ui.actionOpen_Simulation_Run.triggered.connect(self.open_dialog)
window.ui.actionSave_Simulation_Run.triggered.connect(self.save)
window.ui.actionSimulation_Options.triggered.connect(self.open_simulation_settings)
self._update_window()
def new(self) -> None:
self.root = None
self.compiled_model = None
self.path = None
self._update_window()
self.runtime_changed.emit()
logger.info("Created new simulation")
def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None, preserve_plots: bool = False) -> None:
file_path = Path(path)
plot_tabs = deepcopy(self.root.plot_tabs) if preserve_plots and self.root is not None else None
try:
if file_path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(file_path):
self.root = simulation_files.load(file_path)
self.compiled_model = compiled_model or self._recompile_with_wait_cursor(self.root)
self.path = file_path if backed_by_file else None
else:
self.root, self.compiled_model = self._compile_with_wait_cursor(file_path, component=component, simulation=simulation)
self.path = None
except (OSError, RuntimeError, TypeError, ValueError):
logger.exception("Could not open simulation %s", file_path)
raise
if preserve_plots and self.root is not None:
if plot_tabs is not None:
self.root.plot_tabs = plot_tabs
self.root.current_end_time = self.root.settings.start_time
self.root.results.clear()
self._log_compilation()
self._update_window()
self.runtime_changed.emit()
logger.info("Opened simulation: %s", file_path)
def open_handoff(self, path: str | Path, compiled_model: CompiledModel) -> None:
self.open(path, backed_by_file=False, compiled_model=compiled_model, preserve_plots=True)
logger.info("Accepted simulation handoff and reset to time %s", self.root.current_end_time if self.root is not None else "unknown")
def open_dialog(self) -> None:
filename, _ = QFileDialog.getOpenFileName(self.window, "Open Simulation", "", "Simulation and BEdit files (*.bes *.beb *.json)")
if not filename:
return
try:
path = Path(filename)
if path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(path):
self.open(path)
return
document = document_files.load(path)
database = document.metadata.get("simulation_database") if document.metadata is not None else None
entries = [(f"Simulation: {simulation.name}", None, simulation.name) for simulation in getattr(database, "simulations", {}).values()]
entries.extend((f"Component: {component_path}", component_path, None) for _component_id, _component, component_path in component_choices(document))
if not entries:
raise ValueError("the BEdit document contains no components or simulation settings")
label, accepted = QInputDialog.getItem(self.window, "Simulation Target", "Compile:", [entry[0] for entry in entries], 0, False)
if accepted:
_display, component, simulation = next(entry for entry in entries if entry[0] == label)
self.open(path, component=component, simulation=simulation)
except (OSError, RuntimeError, ValueError) as exc:
QMessageBox.critical(self.window, "Could not open simulation", str(exc))
def save(self) -> None:
if self.root is None:
return
path = self.path
if path is None:
filename, _ = QFileDialog.getSaveFileName(self.window, "Save Simulation", "simulation.bes", "BEdit simulation (*.bes);;Simulation JSON (*.json)")
if not filename:
return
path = Path(filename)
if path.suffix.lower() not in (".bes", ".json"):
path = path.with_suffix(".bes")
try:
simulation_files.save(self.root, path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save simulation %s", path)
QMessageBox.critical(self.window, "Could not save simulation", str(exc))
return
self.path = path
self._update_window()
logger.info("Saved simulation: %s", path)
def open_simulation_settings(self) -> None:
if self.root is None:
return
simulation_id = SimulationID()
database = SimulationDatabase(simulations={simulation_id: self.root.settings}, active_simulation=simulation_id)
components = self._source_components()
dialog = SimulationSettingsDialog(database, [(component_id, path) for component_id, _component, path in components], self.window, show_simulation_list=False)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
updated = dialog.database().simulations[simulation_id]
component_changed = updated.component != self.root.component
self.root.settings = updated
self.root.component = updated.component
self.root.component_path = next((path for component_id, _component, path in components if component_id == updated.component), self.root.component_path)
self.root.settings_name = updated.name
self.root.current_end_time = updated.start_time
self.root.results.clear()
if component_changed:
self.compiled_model = None
self._update_window()
self.runtime_changed.emit()
logger.info("Updated simulation settings: %s", updated.name)
def _source_components(self) -> list[tuple]:
if self.root is not None and self.root.source_document is not None:
try:
return component_choices(document_files.load(self.root.source_document))
except (OSError, TypeError, ValueError):
logger.warning("Could not load source components from %s", self.root.source_document, exc_info=True)
if self.root is None:
return []
return [(self.root.component, None, self.root.component_path)]
def _compile_with_wait_cursor(self, path: Path, *, component: str | None, simulation: str | None) -> tuple[SimulationRoot, CompiledModel]:
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation)
finally:
QApplication.restoreOverrideCursor()
def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel:
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
return compile_simulation_root(root)
finally:
QApplication.restoreOverrideCursor()
def _update_window(self) -> None:
if self.root is None:
self.window.setWindowTitle("BEdit Simulator")
self.window.statusBar().showMessage("No simulation loaded")
self.window.ui.actionSave_Simulation_Run.setEnabled(False)
self.window.ui.actionSimulation_Options.setEnabled(False)
return
self.window.setWindowTitle(f"{self.root.settings.name} — BEdit Simulator")
compiled = self.compiled_model.executable if self.compiled_model is not None else "not compiled"
self.window.statusBar().showMessage(f"{self.root.component_path} · {compiled}")
self.window.ui.actionSave_Simulation_Run.setEnabled(True)
self.window.ui.actionSimulation_Options.setEnabled(True)
def _log_compilation(self) -> None:
if self.compiled_model is None:
return
logger.info("Compiled model %s: %s", self.compiled_model.model_name, self.compiled_model.executable)
if self.compiled_model.output.strip():
logger.info("Compiler output:\n%s", self.compiled_model.output.strip())
if self.compiled_model.errors.strip():
logger.warning("Compiler errors:\n%s", self.compiled_model.errors.strip())

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QMessageBox
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
from bedit_gui.controllers.simulation_run_controller import SimulationRunController
from bedit_gui.services.application_logging import get_logger
from bedit_gui.services.simulation_handoff import SimulationHandoffServer
from bedit_gui.simulation_models import CompiledModel
from bedit_gui.views.simulation_window import SimulationWindow
logger = get_logger(__name__)
class SimulationHandoffController(QObject):
def __init__(self, window: SimulationWindow, files: SimulationFileController, runs: SimulationRunController) -> None:
super().__init__(window)
self.window = window
self.files = files
self.runs = runs
self._pending: tuple[Path, CompiledModel] | None = None
self.server = SimulationHandoffServer(self)
self.server.handoff_received.connect(self.receive)
runs.run_completed.connect(self._run_finished)
runs.run_cancelled.connect(self._run_finished)
runs.run_failed.connect(self._run_finished)
def receive(self, path: Path, compiled_model: CompiledModel) -> None:
self._pending = (path, compiled_model)
if self.runs.is_running:
logger.info("Received a new model; stopping the active simulation before handoff")
self.runs.stop()
return
self._apply_pending()
def _run_finished(self, *_args: object) -> None:
if self._pending is not None:
self._apply_pending()
def _apply_pending(self) -> None:
pending = self._pending
self._pending = None
if pending is None:
return
path, compiled_model = pending
try:
self.files.open_handoff(path, compiled_model)
except (OSError, RuntimeError, TypeError, ValueError) as exc:
logger.exception("Could not accept simulation handoff from %s", path)
QMessageBox.critical(self.window, "Could not open simulation", str(exc))
return
self.window.show()
self.window.raise_()
self.window.activateWindow()

View File

@@ -0,0 +1,272 @@
from __future__ import annotations
from PySide6.QtCore import QObject, QPoint, Qt
from PySide6.QtGui import QUndoStack
from PySide6.QtWidgets import QDialog, QInputDialog, QMenu, QTreeWidgetItem, QWidget
from bedit_gui.commands.simulation_plot_commands import AddSimulationPlotTabCommand, ChangeSimulationPlotSettingsCommand, ChangeSimulationPlotSignalsCommand, ChangeSimulationPlotXAxisCommand, RemoveSimulationPlotTabCommand, RenameSimulationPlotTabCommand
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
from bedit_gui.services.application_logging import get_logger
from bedit_gui.simulation_models import SimulationPlotTab
from bedit_gui.views.simulation_plot_widget import SimulationPlotWidget
from bedit_gui.views.dialogs.plot_settings_dialog import PlotSettingsDialog
from bedit_gui.views.simulation_window import SimulationWindow
logger = get_logger(__name__)
def _signal_tree_parts(signal: str) -> list[str]:
if signal.startswith("der(") and signal.endswith(")"):
parts = signal[4:-1].split(".")
if len(parts) > 1:
return [*parts[:-1], f"der({parts[-1]})"]
return signal.split(".")
class SimulationPlotController(QObject):
def __init__(self, window: SimulationWindow, files: SimulationFileController) -> None:
super().__init__(window)
self.window = window
self.files = files
self.undo_stack = QUndoStack(self)
self._updating = False
self._active_tab = -1
tabs = window.ui.resultsTabWidget
tree = window.ui.simulationTree
tabs.currentChanged.connect(self._tab_changed)
tabs.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
tabs.tabBar().customContextMenuRequested.connect(self._show_tab_menu)
tree.itemChanged.connect(self._signal_changed)
tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
tree.customContextMenuRequested.connect(self._show_signal_menu)
files.runtime_changed.connect(self.load_root)
window.ui.actionUndo.triggered.connect(self.undo)
window.ui.actionRedo.triggered.connect(self.redo)
self.undo_stack.canUndoChanged.connect(window.ui.actionUndo.setEnabled)
self.undo_stack.canRedoChanged.connect(window.ui.actionRedo.setEnabled)
self.undo_stack.undoTextChanged.connect(self._set_undo_text)
self.undo_stack.redoTextChanged.connect(self._set_redo_text)
window.ui.actionUndo.setEnabled(False)
window.ui.actionRedo.setEnabled(False)
self.load_root()
def load_root(self) -> None:
self.undo_stack.clear()
self._active_tab = 0 if self.files.root is not None and self.files.root.plot_tabs else -1
self._rebuild_tabs(self._active_tab)
self._rebuild_tree()
def refresh_results(self) -> None:
self._rebuild_tree()
self._refresh_plots()
def add_tab(self) -> None:
root = self.files.root
if root is None:
return
used_names = {tab.name for tab in root.plot_tabs}
number = 1
while f"Plot {number}" in used_names:
number += 1
index = len(root.plot_tabs)
self.undo_stack.push(AddSimulationPlotTabCommand(root, SimulationPlotTab(f"Plot {number}"), index, self._tabs_changed))
def rename_tab(self, index: int, name: str) -> None:
root = self.files.root
name = name.strip()
if root is None or not 0 <= index < len(root.plot_tabs) or not name or root.plot_tabs[index].name == name:
return
self.undo_stack.push(RenameSimulationPlotTabCommand(root, index, name, self._tabs_changed))
def remove_tab(self, index: int) -> None:
root = self.files.root
if root is None or not 0 <= index < len(root.plot_tabs):
return
self.undo_stack.push(RemoveSimulationPlotTabCommand(root, index, self._tabs_changed))
def undo(self) -> None:
command = self.undo_stack.undoText()
self.undo_stack.undo()
logger.info("Undo: %s", command)
def redo(self) -> None:
command = self.undo_stack.redoText()
self.undo_stack.redo()
logger.info("Redo: %s", command)
def _tabs_changed(self, selected_index: int) -> None:
root = self.files.root
if root is None or not root.plot_tabs:
self._active_tab = -1
else:
self._active_tab = max(0, min(selected_index, len(root.plot_tabs) - 1))
self._rebuild_tabs(self._active_tab)
self._rebuild_tree()
def _selection_changed(self, index: int) -> None:
self._active_tab = index
self._sync_tree_checks()
self._refresh_plot(index)
def _rebuild_tabs(self, selected_index: int) -> None:
tabs = self.window.ui.resultsTabWidget
root = self.files.root
self._updating = True
try:
while tabs.count():
tabs.removeTab(0)
if root is not None:
for index, tab in enumerate(root.plot_tabs):
widget = SimulationPlotWidget(tabs)
widget.settings_requested.connect(lambda checked=False, tab_index=index: self._open_plot_settings(tab_index))
tabs.addTab(widget, tab.name)
tabs.addTab(QWidget(tabs), "+")
tabs.setCurrentIndex(selected_index if selected_index >= 0 else tabs.count() - 1)
finally:
self._updating = False
self._refresh_plots()
def _rebuild_tree(self) -> None:
tree = self.window.ui.simulationTree
signals = self._available_signals()
self._updating = True
try:
tree.clear()
nodes: dict[tuple[str, ...], QTreeWidgetItem] = {}
for signal in signals:
parent = tree.invisibleRootItem()
parts = _signal_tree_parts(signal)
for depth, part in enumerate(parts, start=1):
path = tuple(parts[:depth])
item = nodes.get(path)
if item is None:
item = QTreeWidgetItem(parent, [part])
nodes[path] = item
parent = item
item.setData(0, Qt.ItemDataRole.UserRole, signal)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
self._sync_tree_checks()
finally:
self._updating = False
def _sync_tree_checks(self) -> None:
tree = self.window.ui.simulationTree
root = self.files.root
selected = set(root.plot_tabs[self._active_tab].signals) if root is not None and 0 <= self._active_tab < len(root.plot_tabs) else set()
previous = self._updating
self._updating = True
try:
iterator = tree.invisibleRootItem()
pending = [iterator.child(index) for index in range(iterator.childCount())]
while pending:
item = pending.pop()
signal = item.data(0, Qt.ItemDataRole.UserRole)
if signal is not None:
item.setCheckState(0, Qt.CheckState.Checked if signal in selected else Qt.CheckState.Unchecked)
pending.extend(item.child(index) for index in range(item.childCount()))
finally:
self._updating = previous
def _signal_changed(self, item: QTreeWidgetItem, _column: int) -> None:
if self._updating:
return
root = self.files.root
signal = item.data(0, Qt.ItemDataRole.UserRole)
if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs):
return
selected = set(root.plot_tabs[self._active_tab].signals)
if item.checkState(0) == Qt.CheckState.Checked:
selected.add(signal)
else:
selected.discard(signal)
ordered = [available for available in self._available_signals() if available in selected]
if ordered != root.plot_tabs[self._active_tab].signals:
self.undo_stack.push(ChangeSimulationPlotSignalsCommand(root, self._active_tab, ordered, self._selection_changed))
def _tab_changed(self, index: int) -> None:
if self._updating:
return
root = self.files.root
real_tab_count = len(root.plot_tabs) if root is not None else 0
if index == real_tab_count:
self.add_tab()
return
if 0 <= index < real_tab_count:
self._active_tab = index
self._sync_tree_checks()
def _show_tab_menu(self, position: QPoint) -> None:
root = self.files.root
tab_bar = self.window.ui.resultsTabWidget.tabBar()
index = tab_bar.tabAt(position)
if root is None or not 0 <= index < len(root.plot_tabs):
return
menu = QMenu(tab_bar)
rename_action = menu.addAction("Rename")
remove_action = menu.addAction("Remove")
selected = menu.exec(tab_bar.mapToGlobal(position))
if selected is rename_action:
name, accepted = QInputDialog.getText(self.window, "Rename Plot", "Name:", text=root.plot_tabs[index].name)
if accepted:
self.rename_tab(index, name)
elif selected is remove_action:
self.remove_tab(index)
def _show_signal_menu(self, position: QPoint) -> None:
root = self.files.root
tree = self.window.ui.simulationTree
item = tree.itemAt(position)
signal = item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs):
return
tab = root.plot_tabs[self._active_tab]
menu = QMenu(tree)
use_signal = menu.addAction("Use as x axis")
use_signal.setEnabled(tab.x_axis != signal)
use_time = menu.addAction("Use time as x axis")
use_time.setEnabled(tab.x_axis is not None)
selected = menu.exec(tree.viewport().mapToGlobal(position))
if selected is use_signal:
self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, signal, self._selection_changed))
elif selected is use_time:
self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, None, self._selection_changed))
def _open_plot_settings(self, index: int) -> None:
root = self.files.root
if root is None or not 0 <= index < len(root.plot_tabs):
return
dialog = PlotSettingsDialog(root.plot_tabs[index].settings, root.plot_tabs[index].signals, self.window)
if dialog.exec() == QDialog.DialogCode.Accepted:
settings = dialog.settings()
if settings != root.plot_tabs[index].settings:
self.undo_stack.push(ChangeSimulationPlotSettingsCommand(root, index, settings, self._selection_changed))
def _available_signals(self) -> list[str]:
root = self.files.root
if root is None:
return []
return sorted({signal for result in root.results for signal in result.data})
def _refresh_plots(self) -> None:
root = self.files.root
if root is None:
return
for index in range(len(root.plot_tabs)):
self._refresh_plot(index)
def _refresh_plot(self, index: int) -> None:
root = self.files.root
if root is None or not 0 <= index < len(root.plot_tabs):
return
widget = self.window.ui.resultsTabWidget.widget(index)
if isinstance(widget, SimulationPlotWidget):
tab = root.plot_tabs[index]
widget.set_plot(root.results, tab.signals, tab.x_axis, tab.settings)
def _set_undo_text(self, command: str) -> None:
self.window.ui.actionUndo.setText(f"Undo {command}" if command else "Undo")
def _set_redo_text(self, command: str) -> None:
self.window.ui.actionRedo.setText(f"Redo {command}" if command else "Redo")

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from threading import Thread
from PySide6.QtCore import QObject, Signal
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
from bedit_gui.services.application_logging import get_logger
from bedit_gui.simulation_models import CompiledModel
from bedit_gui.views.simulation_window import SimulationWindow
from bedit_simulation import SimulationCancelledError, SimulationResult, SimulationRunSettings, SimulationSession
logger = get_logger(__name__)
SessionFactory = Callable[[CompiledModel, SimulationRunSettings, float | None, list[SimulationResult]], SimulationSession]
def _create_session(compiled: CompiledModel, settings: SimulationRunSettings, current_end_time: float | None, results: list[SimulationResult]) -> SimulationSession:
return SimulationSession(compiled.model_name, compiled.executable, settings, current_end_time=current_end_time, results=results)
class SimulationRunController(QObject):
run_completed = Signal(object)
run_cancelled = Signal()
run_failed = Signal(object)
run_progress = Signal(int)
simulation_state_changed = Signal()
def __init__(self, window: SimulationWindow, files: SimulationFileController, session_factory: SessionFactory = _create_session) -> None:
super().__init__(window)
self.window = window
self.files = files
self.session_factory = session_factory
self.session: SimulationSession | None = None
self._running = False
self._reset_pending = False
self.window.ui.progressBar.setValue(0)
window.ui.actionRun_Simulation.triggered.connect(self.start)
window.ui.actionStop_Simulation.triggered.connect(self.stop)
window.ui.actionRestart_Simulation.triggered.connect(self.restart)
files.runtime_changed.connect(self._load_session)
self.run_completed.connect(self._completed)
self.run_cancelled.connect(self._cancelled)
self.run_failed.connect(self._failed)
self.run_progress.connect(self.window.ui.progressBar.setValue)
self._load_session()
@property
def is_running(self) -> bool:
return self._running
def start(self) -> None:
if self._running or self.session is None:
return
start_time = self.session.settings.start_time
stop_time = self.session.current_end_time + self.session.settings.duration
self._running = True
self.window.ui.progressBar.setValue(0)
self._update_actions()
logger.info("Starting simulation from %s to %s", start_time, stop_time)
Thread(target=self._run_worker, args=(self.session,), name="besim-run", daemon=True).start()
def stop(self) -> None:
if not self._running or self.session is None:
return
self.session.cancel()
logger.info("Stopping simulation")
def restart(self) -> None:
if self.session is None:
return
if self._running:
self._reset_pending = True
logger.info("Reset requested; stopping the active simulation")
self.stop()
return
self._reset()
def _reset(self) -> None:
if self.session is None:
return
self.session.reset()
self.window.ui.progressBar.setValue(0)
self._store_session_state()
logger.info("Reset simulation to start time %s", self.session.current_end_time)
self._update_actions()
def _run_worker(self, session: SimulationSession) -> None:
try:
result = asyncio.run(self._run_with_progress(session))
except SimulationCancelledError:
self.run_cancelled.emit()
except (OSError, RuntimeError, TypeError, ValueError) as exc:
self.run_failed.emit(exc)
else:
self.run_completed.emit(result)
async def _run_with_progress(self, session: SimulationSession) -> SimulationResult:
task = asyncio.create_task(session.run_next())
last_progress = -1
while not task.done():
progress = min(session.get_progress(), 99)
if progress != last_progress:
self.run_progress.emit(progress)
last_progress = progress
await asyncio.sleep(0.1)
return await task
def _completed(self, result: SimulationResult) -> None:
self._running = False
self._store_session_state()
self.window.ui.progressBar.setValue(100)
logger.info("Simulation completed at time %s", self.session.current_end_time if self.session is not None else "unknown")
if result.process_output.strip():
logger.info("Simulation output:\n%s", result.process_output.strip())
if result.process_errors.strip():
logger.warning("Simulation errors:\n%s", result.process_errors.strip())
self._finish_run()
def _cancelled(self) -> None:
self._running = False
logger.info("Simulation stopped")
self._finish_run()
def _failed(self, error: Exception) -> None:
self._running = False
logger.error("Simulation failed: %s", error, exc_info=(type(error), error, error.__traceback__))
self._finish_run()
def _finish_run(self) -> None:
self._update_actions()
if self._reset_pending:
self._reset_pending = False
self._reset()
def _load_session(self) -> None:
root = self.files.root
compiled = self.files.compiled_model
self._reset_pending = False
self.window.ui.progressBar.setValue(0)
if root is None or compiled is None:
self.session = None
else:
settings = root.settings
try:
run_settings = SimulationRunSettings(
start_time=settings.start_time,
duration=settings.duration,
use_timed_steps=settings.use_timed_steps,
number_of_steps=settings.number_of_steps,
step_size=settings.step_size,
tolerance=settings.dassl_tolerance,
method=settings.method.value,
)
self.session = self.session_factory(compiled, run_settings, root.current_end_time, root.results)
except (OSError, RuntimeError, ValueError):
self.session = None
logger.exception("Could not initialize the simulation runtime")
self._update_actions()
def _update_actions(self) -> None:
available = self.session is not None
self.window.ui.actionRun_Simulation.setEnabled(available and not self._running)
self.window.ui.actionStop_Simulation.setEnabled(available and self._running)
self.window.ui.actionRestart_Simulation.setEnabled(available)
for action in (self.window.ui.actionNew_Simulation_Run, self.window.ui.actionOpen_Simulation_Run, self.window.ui.actionSave_Simulation_Run, self.window.ui.actionSimulation_Options):
action.setEnabled(not self._running and (available or action is not self.window.ui.actionSimulation_Options))
if self.session is not None:
state = "running" if self._running else "ready"
self.window.statusBar().showMessage(f"Simulation {state} · current time {self.session.current_end_time}")
def _store_session_state(self) -> None:
if self.session is None or self.files.root is None:
return
self.files.root.current_end_time = self.session.current_end_time
self.files.root.results = list(self.session.results)
self.simulation_state_changed.emit()

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QDialog
from bedit_core.models import Component, ComponentID, GraphImplementation
from bedit_gui.documents import Document
from bedit_gui.models import SimulationDatabase
from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog
from bedit_gui.views.main_window import MainWindow
class SimulationSettingsDialogLike(Protocol):
def exec(self) -> int: ...
def database(self) -> SimulationDatabase: ...
SimulationSettingsDialogFactory = Callable[[SimulationDatabase, list[tuple[ComponentID, str]], MainWindow], SimulationSettingsDialogLike]
class SimulationSettingsController(QObject):
def __init__(self, document: Document, window: MainWindow, dialog_factory: SimulationSettingsDialogFactory = SimulationSettingsDialog) -> None:
super().__init__(window)
self.document = document
self.window = window
self.dialog_factory = dialog_factory
window.ui.actionSimulation_Settings.triggered.connect(self.open_settings)
def open_settings(self) -> None:
dialog = self.dialog_factory(self.document.simulation_database(), self._components(), self.window)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.document.change_simulation_database(dialog.database())
def _components(self) -> list[tuple[ComponentID, str]]:
components: list[tuple[ComponentID, str]] = []
def collect(items: dict[ComponentID, Component], path: tuple[str, ...] = ()) -> None:
for component_id, component in items.items():
component_path = (*path, component.name)
components.append((component_id, ".".join(component_path)))
if isinstance(component.implementation, GraphImplementation):
collect(component.implementation.graph.components, component_path)
collect(self.document.model.root)
return components

View File

@@ -31,11 +31,13 @@ class UndoController(QObject):
redo_action.setEnabled(undo_stack.canRedo()) redo_action.setEnabled(undo_stack.canRedo())
def undo(self) -> None: def undo(self) -> None:
self.window.equation_editor.finish_text_edit()
command = self.document.undo_stack.undoText() command = self.document.undo_stack.undoText()
self.document.undo_stack.undo() self.document.undo_stack.undo()
logger.info("Undo: %s", command) logger.info("Undo: %s", command)
def redo(self) -> None: def redo(self) -> None:
self.window.equation_editor.finish_text_edit()
command = self.document.undo_stack.redoText() command = self.document.undo_stack.redoText()
self.document.undo_stack.redo() self.document.undo_stack.redo()
logger.info("Redo: %s", command) logger.info("Redo: %s", command)

View File

@@ -1,12 +1,29 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack from PySide6.QtGui import QUndoStack
from bedit_core.models import ID 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.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 Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, PortMetadata, PortMetadataDatabase, Simulation, SimulationDatabase
from bedit_gui.services import document_files from bedit_gui.services import document_files
@@ -16,6 +33,13 @@ class Document(QObject):
model_changed = Signal(object) model_changed = Signal(object)
path_changed = Signal(object) path_changed = Signal(object)
modified_changed = Signal(bool) modified_changed = Signal(bool)
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: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -78,3 +102,387 @@ class Document(QObject):
name="Untitled", name="Untitled",
root={}, root={},
) )
def rename(self, name: str) -> None:
self.undo_stack.push(RenameDocumentCommand(self, name))
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():
if candidate is component:
return component_id
if isinstance(candidate.implementation, GraphImplementation):
found = find(candidate.implementation.graph.components)
if found is not None:
return found
return None
component_id = find(self.model.root)
if component_id is None:
raise ValueError("component is not part of this document")
return component_id
def stored_component_icon(self, component_id: ComponentID) -> Icon | None:
database = self._icon_database(False)
return deepcopy(database.icons.get(component_id)) if database is not None else None
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))
def stored_simulation_database(self) -> SimulationDatabase | None:
database = self._simulation_database(False)
return deepcopy(database) if database is not None else None
def simulation_database(self) -> SimulationDatabase:
return self.stored_simulation_database() or SimulationDatabase()
def active_simulation(self) -> Simulation | None:
database = self.simulation_database()
return database.simulations.get(database.active_simulation) if database.active_simulation is not None else None
def change_simulation_database(self, database: SimulationDatabase) -> None:
if database != self.stored_simulation_database():
self.undo_stack.push(ChangeSimulationDatabaseCommand(self, database))
def _set_simulation_database(self, database: SimulationDatabase | None) -> None:
if database is None:
if self.model.metadata is not None:
self.model.metadata.pop("simulation_database", None)
else:
if self.model.metadata is None:
self.model.metadata = {}
self.model.metadata["simulation_database"] = deepcopy(database)
self.simulation_database_changed.emit(self.stored_simulation_database())
def _simulation_database(self, create: bool) -> SimulationDatabase | None:
metadata = self.model.metadata
value = metadata.get("simulation_database") if metadata is not None else None
if isinstance(value, dict):
value = SimulationDatabase.from_data(value)
metadata["simulation_database"] = value
if isinstance(value, SimulationDatabase):
return value
if not create:
return None
if metadata is None:
metadata = {}
self.model.metadata = metadata
database = SimulationDatabase()
metadata["simulation_database"] = database
return database
def _set_component_icon(self, component_id: ComponentID, icon: Icon | None) -> None:
if icon is None:
database = self._icon_database(False)
if database is not None:
database.icons.pop(component_id, None)
if not database.icons:
self.model.metadata.pop("icon_database", None)
else:
self._icon_database(True).icons[component_id] = deepcopy(icon)
self.icon_changed.emit(component_id, self.stored_component_icon(component_id))
def _icon_database(self, create: bool) -> IconDatabase | None:
metadata = self.model.metadata
value = metadata.get("icon_database") if metadata is not None else None
if isinstance(value, dict):
value = IconDatabase.from_data(value)
metadata["icon_database"] = value
if isinstance(value, IconDatabase):
return value
if not create:
return None
if metadata is None:
metadata = {}
self.model.metadata = metadata
database = IconDatabase()
metadata["icon_database"] = database
return database
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)
for port_id in current.keys() - ports.keys()
]
added = [
AddPortCommand(self, component, port_id, ports[port_id])
for port_id in ports.keys() - current.keys()
]
changed = [
ChangePortCommand(self, component, port_id, ports[port_id])
for port_id in current.keys() & ports.keys()
if current[port_id] != ports[port_id]
]
commands = [*removed, *added, *changed]
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()
def update_component_params(self, component: Component, params: dict[ParameterID, Parameter]) -> None:
current = component.parameters
removed = [
RemoveParamCommand(self, component, param_id)
for param_id in current.keys() - params.keys()
]
added = [
AddParamCommand(self, component, param_id, params[param_id])
for param_id in params.keys() - current.keys()
]
changed = [
ChangeParamCommand(self, component, param_id, params[param_id])
for param_id in current.keys() & params.keys()
if current[param_id] != params[param_id]
]
commands = [*removed, *added, *changed]
if not commands:
return
self.undo_stack.beginMacro("Edit parameter")
for command in commands:
self.undo_stack.push(command)
self.undo_stack.endMacro()
def update_component_equation_text(self, component: Component, section: str, text: list[str], edit_id: int) -> None:
command = ChangeEquationTextCommand(self, component, section, text, edit_id)
if command.old_text != command.new_text:
self.undo_stack.push(command)
def add_empty_graph_component(self, component: Component) -> None:
if isinstance(component.implementation, GraphImplementation):
command = AddEmptyGraphComponent(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_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))
def delete_components(self, components: list[Component]) -> None:
if not components:
return
self.undo_stack.beginMacro("Delete components")
for component in components:
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], 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, 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:
return Document._unique_name({component.name for component in components.values()}, name)
@staticmethod
def _unique_name(names: set[str], name: str) -> str:
if name not in names:
return name
index = 0
while f"{name}_{index}" in names:
index += 1
return f"{name}_{index}"

294
src/bedit_gui/models.py Normal file
View File

@@ -0,0 +1,294 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from bedit_core.models import ComponentID, ConnectionID, ID, PortID
class ShapeID(ID):
pass
class SimulationID(ID):
pass
@dataclass
class Shape:
layer: int
type: str | None = None
pos: tuple[int, int] = (0, 0)
@classmethod
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":
return Line.from_data(data)
pos = data.get("pos", [0, 0])
return cls(layer=int(data.get("layer", 0)), type=data.get("type"), pos=(int(pos[0]), int(pos[1])))
def to_data(self) -> dict[str, Any]:
return {"layer": self.layer, "type": self.type, "pos": list(self.pos)}
class LineType(Enum):
NONE = "none"
SOLID = "solid"
DASHED = "dashed"
DOTTED = "dotted"
DASH_DOT = "dash_dot"
@dataclass
class Line(Shape):
type: str = field(init=False, default="line")
end: tuple[int, int] = (32, 0)
line_type: LineType = LineType.SOLID
line_thickness: float = 1.0
line_color: str = "#000000ff"
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Line:
pos = data.get("pos", [0, 0])
end = data.get("end", [32, 0])
return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), end=(int(end[0]), int(end[1])), 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")))
def to_data(self) -> dict[str, Any]:
return {**super().to_data(), "end": list(self.end), "line_type": self.line_type.value, "line_thickness": self.line_thickness, "line_color": self.line_color}
@dataclass
class Rectangle(Shape):
type: str = field(init=False, default="rectangle")
width: float = 32.0
height: float = 32.0
line_type: LineType = LineType.SOLID
line_thickness: float = 1.0
corner_radius: float = 0.0
line_color: str = "#000000ff"
fill_color: str = "#ffffff00"
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Rectangle:
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)), corner_radius=float(data.get("corner_radius", 0.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, "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")
width: float = 32.0
height: float = 16.0
color: str = "#000000ff"
bold: bool = False
italic: bool = False
size: float = 16.0
text: str = ""
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Text:
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", 32.0)), height=float(data.get("height", 16.0)), color=str(data.get("color", "#000000ff")), bold=bool(data.get("bold", False)), italic=bool(data.get("italic", False)), size=float(data.get("size", 16.0)), text=str(data.get("text", "")))
def to_data(self) -> dict[str, Any]:
return {**super().to_data(), "width": self.width, "height": self.height, "color": self.color, "bold": self.bold, "italic": self.italic, "size": self.size, "text": self.text}
@dataclass
class Icon:
shapes: dict[ShapeID, Shape] = field(default_factory=dict)
port_positions: dict[PortID, tuple[int, int]] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Icon:
shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()}
port_positions = {PortID(key): (int(value[0]), int(value[1])) for key, value in data.get("port_positions", {}).items()}
return cls(shapes=shapes, port_positions=port_positions)
def to_data(self) -> dict[str, Any]:
return {"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}, "port_positions": {str(key): list(position) for key, position in self.port_positions.items()}}
@dataclass
class IconDatabase:
format_version: int = 1
icons: dict[ComponentID, Icon] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> IconDatabase:
icons = {ComponentID(key): Icon.from_data(value) for key, value in data.get("icons", {}).items()}
return cls(format_version=int(data.get("format_version", 1)), icons=icons)
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"
@dataclass
class Simulation:
component: ComponentID
name: str
start_time: float
duration: float
use_timed_steps: bool
number_of_steps: int
step_size: float
method: SimulationMethod
dassl_tolerance: float
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Simulation:
component = data.get("component")
use_timed_steps = data.get("use_timed_steps", False)
if isinstance(use_timed_steps, str):
use_timed_steps = use_timed_steps.lower() == "true"
return cls(
component=ComponentID(str(component)) if component else ComponentID(),
name=str(data.get("name", "")),
start_time=float(data.get("start_time", 0.0)),
duration=float(data.get("duration", 1.0)),
use_timed_steps=bool(use_timed_steps),
number_of_steps=int(data.get("number_of_steps", 500)),
step_size=float(data.get("step_size", 0.001)),
method=SimulationMethod(data.get("method", "dassl")),
dassl_tolerance=float(data.get("dassl_tolerance", 1e-6)),
)
def to_data(self) -> dict[str, Any]:
return {
"component": str(self.component),
"name": self.name,
"start_time": self.start_time,
"duration": self.duration,
"use_timed_steps": self.use_timed_steps,
"number_of_steps": self.number_of_steps,
"step_size": self.step_size,
"method": self.method.value,
"dassl_tolerance": self.dassl_tolerance,
}
@dataclass
class SimulationDatabase:
format_version: int = 1
simulations: dict[SimulationID, Simulation] = field(default_factory=dict)
active_simulation: SimulationID | None = None
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> SimulationDatabase:
sims = {SimulationID(key): Simulation.from_data(value) for key, value in data.get("simulations", {}).items()}
active = data.get("active_simulation")
active_id = SimulationID(str(active)) if active is not None else None
return cls(format_version=int(data.get("format_version", 1)), simulations=sims, active_simulation=active_id if active_id in sims else None)
def to_data(self) -> dict[str, Any]:
return {"format_version": self.format_version, "active_simulation": str(self.active_simulation) if self.active_simulation is not None else None, "simulations": {str(key): sim.to_data() for key, sim in self.simulations.items()}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,5 +1,9 @@
<RCC> <RCC>
<qresource prefix="icons"> <qresource prefix="icons">
<file>icons/media-skip-backward.png</file>
<file>icons/media-playback-stop.png</file>
<file>icons/edit-delete.png</file>
<file>icons/dialog-close.png</file>
<file>icons/list-remove.png</file> <file>icons/list-remove.png</file>
<file>icons/list-add.png</file> <file>icons/list-add.png</file>
<file>icons/view-form-table.png</file> <file>icons/view-form-table.png</file>

View File

@@ -8,6 +8,51 @@ from PySide6.QtCore import QSettings
class ApplicationSettings: class ApplicationSettings:
"""Typed access to persistent BEdit application settings.""" """Typed access to persistent BEdit application settings."""
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()
@property
def log_level(self) -> int:
return self._settings.value(
self.LOG_LEVEL_KEY,
self.DEFAULT_LOG_LEVEL,
type=int,
)
@log_level.setter
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."""
LOG_LEVEL_KEY = "logging/level" LOG_LEVEL_KEY = "logging/level"
DEFAULT_LOG_LEVEL = logging.INFO DEFAULT_LOG_LEVEL = logging.INFO

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import json
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 = COMPONENTS_MIME
changed = Signal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._clipboard = QGuiApplication.clipboard()
self._clipboard.dataChanged.connect(self.changed)
def set_json(self, mime_type: str, data: dict[str, Any], text: str = "") -> None:
mime = QMimeData()
mime.setData(mime_type, json.dumps(data).encode("utf-8"))
if text:
mime.setText(text)
self._clipboard.setMimeData(mime, QClipboard.Mode.Clipboard)
def get_json(self, mime_type: str) -> dict[str, Any] | None:
mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
if mime is None or not mime.hasFormat(mime_type):
return None
try:
data = json.loads(bytes(mime.data(mime_type)).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def has_format(self, mime_type: str) -> bool:
mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
return mime is not None and mime.hasFormat(mime_type)
def has_text(self) -> bool:
mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
return mime is not None and mime.hasText()

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from copy import deepcopy
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, 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}
component_ids = _all_component_ids(roots)
icons = {}
port_metadata = {}
for component_id in component_ids:
icon = document.stored_component_icon(component_id)
if icon is not None:
icons[component_id] = icon
database = document.port_metadata_database()
for port_id in _all_port_ids(roots):
if port_id in database.ports:
port_metadata[port_id] = database.ports[port_id]
return export_component_data(roots, icons, port_metadata)
def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], 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")
if not isinstance(components, dict):
raise TypeError("component clipboard payload must contain a components object")
clipboard_document = document_from_data({"format_version": 1, "id": str(ID()), "name": "Clipboard", "root": components, "metadata": None})
component_map: dict[ComponentID, ComponentID] = {}
port_map: dict[PortID, PortID] = {}
remapped = _remap_components(clipboard_document.root, component_map, port_map)
icon_data = payload.get("icons", {})
if not isinstance(icon_data, dict):
raise TypeError("component clipboard icons must be an object")
icons: dict[ComponentID, Icon] = {}
for old_id, data in icon_data.items():
new_component_id = component_map.get(ComponentID(old_id))
if new_component_id is None or not isinstance(data, dict):
continue
icon = Icon.from_data(data)
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
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]:
remapped: dict[ComponentID, Component] = {}
for old_component_id, original in components.items():
component = deepcopy(original)
new_component_id = ComponentID()
component_map[old_component_id] = new_component_id
component.interface.ports = {_new_port_id(old_id, port_map): port for old_id, port in component.interface.ports.items()}
component.parameters = {ParameterID(): parameter for parameter in component.parameters.values()}
if isinstance(component.implementation, GraphImplementation):
graph = component.implementation.graph
graph.components = _remap_components(graph.components, component_map, port_map)
graph.connections = {ConnectionID(): connection for connection in graph.connections.values()}
for connection in graph.connections.values():
connection.source = port_map.get(connection.source, connection.source)
connection.target = port_map.get(connection.target, connection.target)
remapped[new_component_id] = component
return remapped
def _new_port_id(old_id: PortID, port_map: dict[PortID, PortID]) -> PortID:
new_id = PortID()
port_map[old_id] = new_id
return new_id
def _all_component_ids(components: dict[ComponentID, Component]) -> list[ComponentID]:
component_ids: list[ComponentID] = []
for component_id, component in components.items():
component_ids.append(component_id)
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

@@ -1,17 +1,37 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from pathlib import Path from pathlib import Path
from bedit_core.models import Document from bedit_core.models import Document
from bedit_core.serialization import load as load_document from bedit_core.serialization import load as load_document
from bedit_core.serialization import save as save_document from bedit_core.serialization import save as save_document
from bedit_gui.models import GraphDatabase, IconDatabase, PortMetadataDatabase, SimulationDatabase
def load(path: str | Path) -> Document: def load(path: str | Path) -> Document:
"""Load a supported document file into the core model.""" """Load a supported document file into the core model."""
return load_document(path) 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
def save(document: Document, path: str | Path) -> None: def save(document: Document, path: str | Path) -> None:
"""Save a core model using the format selected by its file extension.""" """Save a core model using the format selected by its file extension."""
save_document(document, path) 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,17 @@
from __future__ import annotations
import json
from pathlib import Path
from bedit_gui.models import Icon
def load(path: str | Path) -> Icon:
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise TypeError("icon file must contain a JSON object")
return Icon.from_data(data)
def save(icon: Icon, path: str | Path) -> None:
Path(path).write_text(json.dumps(icon.to_data(), indent=2) + "\n", encoding="utf-8")

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

@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import zlib
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import msgpack
from bedit_gui.simulation_models import SimulationRoot
BES_MAGIC = b"BES\x00"
FILE_FORMAT_VERSION = 1
_VERSION_SIZE = 4
def load(path: str | Path) -> SimulationRoot:
file_path = Path(path)
data = _load_json(file_path) if file_path.suffix.lower() == ".json" else _load_bes(file_path)
return SimulationRoot.from_data(data)
def save(root: SimulationRoot, path: str | Path) -> None:
file_path = Path(path)
if file_path.suffix.lower() == ".json":
_save_json(root.to_data(), file_path)
elif file_path.suffix.lower() == ".bes":
_save_bes(root.to_data(), file_path)
else:
raise ValueError(f"unsupported simulation file extension {file_path.suffix!r}; expected '.json' or '.bes'")
def is_simulation_json(path: str | Path) -> bool:
file_path = Path(path)
if file_path.suffix.lower() != ".json":
return False
try:
return _load_json(file_path).get("root_type") == "simulation_root"
except (TypeError, ValueError):
return False
def _load_json(path: Path) -> Mapping[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError(f"could not read simulation JSON {path}: {exc}") from exc
if not isinstance(data, Mapping):
raise TypeError(f"simulation JSON {path} must contain an object")
return data
def _save_json(data: Mapping[str, Any], path: Path) -> None:
path.write_text(json.dumps({"file_format_version": FILE_FORMAT_VERSION, **data}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _load_bes(path: Path) -> Mapping[str, Any]:
try:
payload = path.read_bytes()
header_end = len(BES_MAGIC) + _VERSION_SIZE
if not payload.startswith(BES_MAGIC):
raise ValueError("missing BES file header")
if len(payload) < header_end:
raise ValueError("truncated BES file header")
version = int.from_bytes(payload[len(BES_MAGIC):header_end], "big")
if version != FILE_FORMAT_VERSION:
raise ValueError(f"unsupported BES file version {version}")
data = msgpack.unpackb(zlib.decompress(payload[header_end:]), raw=False, strict_map_key=False)
except (OSError, ValueError, zlib.error, msgpack.exceptions.UnpackException) as exc:
raise ValueError(f"could not read BES simulation {path}: {exc}") from exc
if not isinstance(data, Mapping):
raise TypeError(f"BES simulation {path} must contain a map")
return data
def _save_bes(data: Mapping[str, Any], path: Path) -> None:
encoded = zlib.compress(msgpack.packb(dict(data), use_bin_type=True))
path.write_bytes(BES_MAGIC + FILE_FORMAT_VERSION.to_bytes(_VERSION_SIZE, "big") + encoded)

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import json
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from bedit_gui.simulation_models import CompiledModel
SERVER_NAME = "bedit-besim-handoff-v1"
def send_simulation_handoff(path: str | Path, compiled_model: CompiledModel, *, server_name: str = SERVER_NAME, timeout_ms: int = 500) -> bool:
socket = QLocalSocket()
socket.connectToServer(server_name)
if not socket.waitForConnected(timeout_ms):
return False
payload = json.dumps({"path": str(Path(path).resolve()), "compiled_model": compiled_model.to_data()}, separators=(",", ":")).encode("utf-8") + b"\n"
if socket.write(payload) != len(payload) or not socket.waitForBytesWritten(timeout_ms):
socket.abort()
return False
socket.disconnectFromServer()
return True
class SimulationHandoffServer(QObject):
handoff_received = Signal(object, object)
def __init__(self, parent: QObject | None = None, *, server_name: str = SERVER_NAME) -> None:
super().__init__(parent)
self.server = QLocalServer(self)
self._buffers: dict[QLocalSocket, bytearray] = {}
self.server.newConnection.connect(self._accept_connections)
if not self.server.listen(server_name):
probe = QLocalSocket()
probe.connectToServer(server_name)
if probe.waitForConnected(200):
probe.disconnectFromServer()
return
QLocalServer.removeServer(server_name)
if not self.server.listen(server_name):
raise RuntimeError(f"could not listen for BEsim handoffs: {self.server.errorString()}")
def _accept_connections(self) -> None:
while self.server.hasPendingConnections():
socket = self.server.nextPendingConnection()
if socket is None:
continue
self._buffers[socket] = bytearray()
socket.readyRead.connect(lambda active=socket: self._read(active))
socket.disconnected.connect(lambda active=socket: self._discard(active))
self._read(socket)
def _read(self, socket: QLocalSocket) -> None:
buffer = self._buffers.get(socket)
if buffer is None:
return
buffer.extend(socket.readAll().data())
while b"\n" in buffer:
raw_message, _, remaining = buffer.partition(b"\n")
buffer[:] = remaining
try:
message = json.loads(raw_message.decode("utf-8"))
path = Path(str(message["path"]))
compiled_model = CompiledModel.from_data(message["compiled_model"])
except (KeyError, TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
continue
self.handoff_received.emit(path, compiled_model)
def _discard(self, socket: QLocalSocket) -> None:
self._buffers.pop(socket, None)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from bedit_core.models import Component, ComponentID, Document, GraphImplementation
from bedit_gui.models import Simulation, SimulationMethod
from bedit_gui.services import document_files
from bedit_gui.simulation_models import CompiledModel, SimulationRoot
from bedit_simulation import compile_component_sync
def component_choices(document: Document) -> list[tuple[ComponentID, Component, str]]:
choices: list[tuple[ComponentID, Component, str]] = []
def collect(items: dict[ComponentID, Component], path: tuple[str, ...] = ()) -> None:
for component_id, component in items.items():
component_path = (*path, component.name)
choices.append((component_id, component, ".".join(component_path)))
if isinstance(component.implementation, GraphImplementation):
collect(component.implementation.graph.components, component_path)
collect(document.root)
return choices
def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc") -> tuple[SimulationRoot, CompiledModel]:
"""Open a BEdit document, resolve one launch target, and compile it."""
if bool(component_selector) == bool(simulation_selector):
raise ValueError("specify exactly one component or simulation settings block")
source_path = Path(path).resolve()
document = document_files.load(source_path)
choices = component_choices(document)
settings_name: str | None = None
if component_selector is not None:
component_id, component, component_path = _find_component(choices, component_selector)
settings = _default_settings(component_id)
else:
database = document.metadata.get("simulation_database") if document.metadata is not None else None
if database is None or not hasattr(database, "simulations"):
raise ValueError("the BEdit document does not contain simulation settings")
matches = [(simulation_id, simulation) for simulation_id, simulation in database.simulations.items() if simulation.name == simulation_selector or str(simulation_id) == simulation_selector]
if len(matches) != 1:
raise ValueError(f"simulation settings {simulation_selector!r} were not found or are ambiguous")
_simulation_id, settings = matches[0]
component_id, component, component_path = _find_component(choices, str(settings.component))
settings_name = settings.name
build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
root = SimulationRoot(
format_version=1,
source_document=str(source_path),
source_document_id=str(document.id),
component=component_id,
component_path=component_path,
settings_name=settings_name,
settings=settings,
)
return root, _compile(component, build_directory, omc_command)
def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc") -> CompiledModel:
if root.source_document is None:
raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled")
document = document_files.load(root.source_document)
_component_id, component, _component_path = _find_component(component_choices(document), str(root.component))
build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-"))
return _compile(component, build_directory, omc_command)
def _find_component(choices: list[tuple[ComponentID, Component, str]], selector: str) -> tuple[ComponentID, Component, str]:
matches = [choice for choice in choices if str(choice[0]) == selector or choice[2] == selector]
if len(matches) != 1:
raise ValueError(f"component {selector!r} was not found or is ambiguous")
return matches[0]
def _default_settings(component_id: ComponentID) -> Simulation:
return Simulation(
component=component_id,
name="Default",
start_time=0.0,
duration=1.0,
use_timed_steps=False,
number_of_steps=500,
step_size=0.002,
method=SimulationMethod.DASSL,
dassl_tolerance=1e-6,
)
def _compile(component: Component, working_directory: Path, omc_command: str) -> CompiledModel:
build = compile_component_sync(component, working_directory, omc_command=omc_command)
return CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve()), build.output, build.errors)

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import argparse
import sys
from PySide6.QtWidgets import QApplication, QMessageBox
from bedit_gui.controllers.simulation_file_controller import SimulationFileController
from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.simulation_plot_controller import SimulationPlotController
from bedit_gui.controllers.simulation_run_controller import SimulationRunController
from bedit_gui.controllers.simulation_handoff_controller import SimulationHandoffController
from bedit_gui.simulation_models import CompiledModel
from bedit_gui.services.application_settings import SimulationApplicationSettings
from bedit_gui.views.simulation_window import SimulationWindow
from bedit_gui.versions import BESIM_VERSION
def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Open and run BEdit simulations")
parser.add_argument("--version", action="version", version=f"BEsim {BESIM_VERSION}")
parser.add_argument("file", nargs="?", help="BEdit (.beb/.json) or simulation (.bes/.json) file")
parser.add_argument("-f", "--file", dest="file_option", help=argparse.SUPPRESS)
parser.add_argument("--handoff", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--model-name", help=argparse.SUPPRESS)
parser.add_argument("--executable", help=argparse.SUPPRESS)
parser.add_argument("--working-directory", help=argparse.SUPPRESS)
target = parser.add_mutually_exclusive_group()
target.add_argument("-c", "--component", help="Component ID or dotted path in a BEdit file")
target.add_argument("-s", "--simulation", help="Simulation settings name or ID in a BEdit file")
args = parser.parse_args(arguments)
args.file = args.file_option or args.file
if args.handoff and not all((args.model_name, args.executable, args.working_directory)):
parser.error("a simulator handoff requires compiled-model arguments")
return args
def main(arguments: list[str] | None = None) -> int:
args = parse_arguments(arguments)
app = QApplication(sys.argv if arguments is None else [sys.argv[0], *arguments])
app.setOrganizationName("BEsim")
app.setApplicationName("BEsim")
app.setApplicationVersion(BESIM_VERSION)
window = SimulationWindow()
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}"))
window.ui.actionAbout_QT.triggered.connect(app.aboutQt)
controller = SimulationFileController(window)
settings = SimulationApplicationSettings()
LogController(window, settings.log_level)
run_controller = SimulationRunController(window, controller)
plot_controller = SimulationPlotController(window, controller)
SimulationHandoffController(window, controller, run_controller)
run_controller.simulation_state_changed.connect(plot_controller.refresh_results)
window.showMaximized()
if args.file:
try:
compiled_model = CompiledModel(args.model_name, args.executable, args.working_directory) if args.handoff else None
controller.open(args.file, component=args.component, simulation=args.simulation, backed_by_file=not args.handoff, compiled_model=compiled_model)
except (OSError, ValueError, RuntimeError) as exc:
QMessageBox.critical(window, "Could not open simulation", str(exc))
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from bedit_core.models import ComponentID
from bedit_gui.models import Simulation
from bedit_simulation import SimulationResult
@dataclass
class CompiledModel:
model_name: str
executable: str
working_directory: str
output: str = ""
errors: str = ""
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> CompiledModel:
return cls(model_name=str(data["model_name"]), executable=str(data["executable"]), working_directory=str(data["working_directory"]), output=str(data.get("output", "")), errors=str(data.get("errors", "")))
def to_data(self) -> dict[str, Any]:
return {"model_name": self.model_name, "executable": self.executable, "working_directory": self.working_directory, "output": self.output, "errors": self.errors}
@dataclass
class SimulationTraceSettings:
visible: bool = True
label: str = ""
color: str = ""
line_style: str = "-"
line_width: float = 1.5
marker: str = ""
marker_size: float = 6.0
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> SimulationTraceSettings:
return cls(visible=bool(data.get("visible", True)), label=str(data.get("label", "")), color=str(data.get("color", "")), line_style=str(data.get("line_style", "-")), line_width=float(data.get("line_width", 1.5)), marker=str(data.get("marker", "")), marker_size=float(data.get("marker_size", 6.0)))
def to_data(self) -> dict[str, Any]:
return {"visible": self.visible, "label": self.label, "color": self.color, "line_style": self.line_style, "line_width": self.line_width, "marker": self.marker, "marker_size": self.marker_size}
@dataclass
class SimulationPlotSettings:
title: str = ""
x_label: str = ""
y_label: str = ""
x_scale: str = "linear"
y_scale: str = "linear"
x_auto: bool = True
y_auto: bool = True
x_min: float = 0.0
x_max: float = 1.0
y_min: float = 0.0
y_max: float = 1.0
grid_visible: bool = True
grid_axis: str = "both"
grid_style: str = "-"
grid_alpha: float = 0.5
legend_visible: bool = True
legend_location: str = "best"
traces: dict[str, SimulationTraceSettings] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotSettings:
raw_traces = data.get("traces", {})
if not isinstance(raw_traces, Mapping):
raise TypeError("plot trace settings must be a mapping")
return cls(
title=str(data.get("title", "")), x_label=str(data.get("x_label", "")), y_label=str(data.get("y_label", "")),
x_scale=str(data.get("x_scale", "linear")), y_scale=str(data.get("y_scale", "linear")),
x_auto=bool(data.get("x_auto", True)), y_auto=bool(data.get("y_auto", True)),
x_min=float(data.get("x_min", 0.0)), x_max=float(data.get("x_max", 1.0)), y_min=float(data.get("y_min", 0.0)), y_max=float(data.get("y_max", 1.0)),
grid_visible=bool(data.get("grid_visible", True)), grid_axis=str(data.get("grid_axis", "both")), grid_style=str(data.get("grid_style", "-")), grid_alpha=float(data.get("grid_alpha", 0.5)),
legend_visible=bool(data.get("legend_visible", True)), legend_location=str(data.get("legend_location", "best")),
traces={str(signal): SimulationTraceSettings.from_data(trace) for signal, trace in raw_traces.items() if isinstance(trace, Mapping)},
)
def to_data(self) -> dict[str, Any]:
return {
"title": self.title, "x_label": self.x_label, "y_label": self.y_label, "x_scale": self.x_scale, "y_scale": self.y_scale,
"x_auto": self.x_auto, "y_auto": self.y_auto, "x_min": self.x_min, "x_max": self.x_max, "y_min": self.y_min, "y_max": self.y_max,
"grid_visible": self.grid_visible, "grid_axis": self.grid_axis, "grid_style": self.grid_style, "grid_alpha": self.grid_alpha,
"legend_visible": self.legend_visible, "legend_location": self.legend_location,
"traces": {signal: trace.to_data() for signal, trace in self.traces.items()},
}
@dataclass
class SimulationPlotTab:
name: str
signals: list[str] = field(default_factory=list)
x_axis: str | None = None
settings: SimulationPlotSettings = field(default_factory=SimulationPlotSettings)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotTab:
raw_signals = data.get("signals", [])
if not isinstance(raw_signals, list):
raise TypeError("plot tab signals must be a list")
raw_settings = data.get("settings", {})
if not isinstance(raw_settings, Mapping):
raise TypeError("plot tab settings must be a mapping")
return cls(name=str(data.get("name", "Plot")), signals=[str(signal) for signal in raw_signals], x_axis=str(data["x_axis"]) if data.get("x_axis") is not None else None, settings=SimulationPlotSettings.from_data(raw_settings))
def to_data(self) -> dict[str, Any]:
return {"name": self.name, "signals": self.signals, "x_axis": self.x_axis, "settings": self.settings.to_data()}
@dataclass
class SimulationRoot:
format_version: int
source_document: str | None
source_document_id: str | None
component: ComponentID
component_path: str
settings_name: str | None
settings: Simulation
current_end_time: float | None = None
results: list[SimulationResult] = field(default_factory=list)
plot_tabs: list[SimulationPlotTab] = field(default_factory=lambda: [SimulationPlotTab("Plot 1")])
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot:
if data.get("root_type") != "simulation_root":
raise ValueError("file does not contain a simulation root")
return cls(
format_version=int(data.get("format_version", 1)),
source_document=str(data["source_document"]) if data.get("source_document") is not None else None,
source_document_id=str(data["source_document_id"]) if data.get("source_document_id") is not None else None,
component=ComponentID(str(data["component"])),
component_path=str(data.get("component_path", "")),
settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None,
settings=Simulation.from_data(data["settings"]),
current_end_time=float(data["current_end_time"]) if data.get("current_end_time") is not None else None,
results=[_result_from_data(result) for result in data.get("results", [])],
plot_tabs=[SimulationPlotTab.from_data(tab) for tab in data["plot_tabs"]] if "plot_tabs" in data else [SimulationPlotTab("Plot 1")],
)
def to_data(self) -> dict[str, Any]:
return {
"root_type": "simulation_root",
"format_version": self.format_version,
"source_document": self.source_document,
"source_document_id": self.source_document_id,
"component": str(self.component),
"component_path": self.component_path,
"settings_name": self.settings_name,
"settings": self.settings.to_data(),
"current_end_time": self.current_end_time,
"results": [_result_to_data(result) for result in self.results],
"plot_tabs": [tab.to_data() for tab in self.plot_tabs],
}
def _result_from_data(data: Mapping[str, Any]) -> SimulationResult:
raw_columns = data.get("data", {})
if not isinstance(raw_columns, Mapping):
raise TypeError("simulation result data must be a mapping")
columns = {str(name): [float(value) for value in values] for name, values in raw_columns.items()}
return SimulationResult(model_name=str(data.get("model_name", "")), data=columns, process_output=str(data.get("process_output", "")), process_errors=str(data.get("process_errors", "")))
def _result_to_data(result: SimulationResult) -> dict[str, Any]:
return {"model_name": result.model_name, "data": result.data, "process_output": result.process_output, "process_errors": result.process_errors}

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>equationEditorWidget</class>
<widget class="QWidget" name="equationEditorWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1079</width>
<height>730</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="declarationEditor">
<property name="text">
<string>Declarations</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="declarationsTextEdit"/>
</item>
<item>
<widget class="QLabel" name="initialEquationEditor">
<property name="text">
<string>Initial equations</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="initialEquationsTextEdit"/>
</item>
<item>
<widget class="QLabel" name="equationEditor">
<property name="text">
<string>Equations</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="equationsTextEdit"/>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item alignment="Qt::AlignmentFlag::AlignLeft">
<widget class="QToolButton" name="sidebarButton">
<property name="toolTip">
<string>Hide parameter and port editors</string>
</property>
<property name="arrowType">
<enum>Qt::ArrowType::RightArrow</enum>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="paramEditor">
<property name="text">
<string>paramEditor</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="portEditor">
<property name="text">
<string>portEditor</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>graphEditorWidget</class>
<widget class="QWidget" name="graphEditorWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1079</width>
<height>730</height>
</rect>
</property>
<property name="windowTitle">
<string>Graph Editor</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolBar" name="graphToolBar">
<property name="windowTitle">
<string>Graph tools</string>
</property>
<property name="movable">
<bool>false</bool>
</property>
<property name="floatable">
<bool>false</bool>
</property>
<addaction name="actionZoomToFit"/>
<addaction name="actionMouseMode"/>
<addaction name="actionConnectionMode"/>
</widget>
</item>
<item>
<widget class="QGraphicsView" name="graphicsView">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</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="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>Zoom to Fit</string>
</property>
<property name="toolTip">
<string>Zoom canvas to fit</string>
</property>
</action>
<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>Mouse Mode</string>
</property>
<property name="toolTip">
<string>Mouse Mode</string>
</property>
</action>
<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>Connection Mode</string>
</property>
<property name="toolTip">
<string>Connection Mode</string>
</property>
</action>
</widget>
<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

@@ -0,0 +1,260 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>iconEditor</class>
<widget class="QMainWindow" name="iconEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="windowIcon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-path.png</normaloff>:/icons/icons/draw-path.png</iconset>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGraphicsView" name="graphicsView"/>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>19</height>
</rect>
</property>
<widget class="QMenu" name="menuEdit">
<property name="title">
<string>Edit</string>
</property>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
</widget>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="actionOpen_from_File"/>
<addaction name="actionSave_to_File"/>
<addaction name="separator"/>
<addaction name="actionSave"/>
<addaction name="actionCancel"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuEdit"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QToolBar" name="actionToolbar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="actionSave"/>
<addaction name="actionCancel"/>
</widget>
<widget class="QToolBar" name="iconToolbar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionAdd_Rectangle"/>
<addaction name="actionAdd_Circle"/>
<addaction name="actionAdd_Text"/>
<addaction name="actionAdd_Line"/>
</widget>
<action name="actionUndo">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
</property>
<property name="text">
<string>Undo</string>
</property>
<property name="toolTip">
<string>Undo</string>
</property>
<property name="shortcut">
<string>Ctrl+Z</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionRedo">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
</property>
<property name="text">
<string>Redo</string>
</property>
<property name="toolTip">
<string>Redo</string>
</property>
<property name="shortcut">
<string>Ctrl+Y</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSave">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-save.png</normaloff>:/icons/icons/document-save.png</iconset>
</property>
<property name="text">
<string>Save</string>
</property>
<property name="toolTip">
<string>Save icon</string>
</property>
<property name="shortcut">
<string>Return</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCancel">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/dialog-close.png</normaloff>:/icons/icons/dialog-close.png</iconset>
</property>
<property name="text">
<string>Cancel</string>
</property>
<property name="toolTip">
<string>Cancel icon editing</string>
</property>
<property name="shortcut">
<string>Shift+Esc</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAdd_Rectangle">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-rectangle.png</normaloff>:/icons/icons/draw-rectangle.png</iconset>
</property>
<property name="text">
<string>Add Rectangle</string>
</property>
<property name="toolTip">
<string>Add a rectangle</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAdd_Text">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-text.png</normaloff>:/icons/icons/draw-text.png</iconset>
</property>
<property name="text">
<string>Add Text</string>
</property>
<property name="toolTip">
<string>Add a text field</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSave_to_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-save-as.png</normaloff>:/icons/icons/document-save-as.png</iconset>
</property>
<property name="text">
<string>Save to File</string>
</property>
<property name="toolTip">
<string>Save icon to a file</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionOpen_from_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-open.png</normaloff>:/icons/icons/document-open.png</iconset>
</property>
<property name="text">
<string>Open from File</string>
</property>
<property name="toolTip">
<string>Open icon from File</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+O</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAdd_Line">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-path.png</normaloff>:/icons/icons/draw-path.png</iconset>
</property>
<property name="text">
<string>Add Line</string>
</property>
<property name="toolTip">
<string>Add a line</string>
</property>
<property name="menuRole">
<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"/>
</resources>
<connections/>
</ui>

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> <x>0</x>
<y>0</y> <y>0</y>
<width>940</width> <width>940</width>
<height>22</height> <height>19</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
@@ -52,7 +52,14 @@
<addaction name="actionUndo"/> <addaction name="actionUndo"/>
<addaction name="actionRedo"/> <addaction name="actionRedo"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionDelete"/>
<addaction name="separator"/>
<addaction name="actionSettings"/> <addaction name="actionSettings"/>
<addaction name="actionReload_Libraries"/>
</widget> </widget>
<widget class="QMenu" name="menuView"> <widget class="QMenu" name="menuView">
<property name="title"> <property name="title">
@@ -66,11 +73,23 @@
<property name="title"> <property name="title">
<string>Help</string> <string>Help</string>
</property> </property>
<addaction name="actionAbout"/>
<addaction name="actionAbout_QT"/> <addaction name="actionAbout_QT"/>
</widget> </widget>
<widget class="QMenu" name="menuSimulation">
<property name="title">
<string>Simulation</string>
</property>
<addaction name="actionSimulation_Settings"/>
<addaction name="actionEdit_Parameters"/>
<addaction name="separator"/>
<addaction name="actionCompile_Model"/>
<addaction name="actionOpen_Simulation_Window"/>
</widget>
<addaction name="menuFile"/> <addaction name="menuFile"/>
<addaction name="menuEdit"/> <addaction name="menuEdit"/>
<addaction name="menuView"/> <addaction name="menuView"/>
<addaction name="menuSimulation"/>
<addaction name="menuHelp"/> <addaction name="menuHelp"/>
</widget> </widget>
<widget class="QStatusBar" name="statusbar"/> <widget class="QStatusBar" name="statusbar"/>
@@ -101,12 +120,15 @@
</attribute> </attribute>
<addaction name="actionUndo"/> <addaction name="actionUndo"/>
<addaction name="actionRedo"/> <addaction name="actionRedo"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
</widget> </widget>
<widget class="QDockWidget" name="documentTreeWidget"> <widget class="QDockWidget" name="documentTreeWidget">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>150</width> <width>150</width>
<height>533</height> <height>200</height>
</size> </size>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
@@ -144,6 +166,42 @@
</layout> </layout>
</widget> </widget>
</widget> </widget>
<widget class="QToolBar" name="simToolBar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionSimulation_Settings"/>
<addaction name="actionEdit_Parameters"/>
<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"> <action name="actionOpen_File">
<property name="icon"> <property name="icon">
<iconset resource="../../resources/resources.qrc"> <iconset resource="../../resources/resources.qrc">
@@ -294,6 +352,153 @@
<string>Settings</string> <string>Settings</string>
</property> </property>
</action> </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">
<normaloff>:/icons/icons/edit-delete.png</normaloff>:/icons/icons/edit-delete.png</iconset>
</property>
<property name="text">
<string>Delete</string>
</property>
<property name="toolTip">
<string>Delete selected</string>
</property>
<property name="shortcut">
<string>Del</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionEscape">
<property name="text">
<string>Escape</string>
</property>
<property name="shortcut">
<string>Esc</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCopy">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-copy.png</normaloff>:/icons/icons/edit-copy.png</iconset>
</property>
<property name="text">
<string>Copy</string>
</property>
<property name="toolTip">
<string>Copy selected</string>
</property>
<property name="shortcut">
<string>Ctrl+C</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPaste">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-paste.png</normaloff>:/icons/icons/edit-paste.png</iconset>
</property>
<property name="text">
<string>Paste</string>
</property>
<property name="toolTip">
<string>Paste selected</string>
</property>
<property name="shortcut">
<string>Ctrl+V</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCut">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-cut.png</normaloff>:/icons/icons/edit-cut.png</iconset>
</property>
<property name="text">
<string>Cut</string>
</property>
<property name="toolTip">
<string>Cut selected</string>
</property>
<property name="shortcut">
<string>Ctrl+X</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSimulation_Settings">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/configure.png</normaloff>:/icons/icons/configure.png</iconset>
</property>
<property name="text">
<string>Simulation Settings</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionEdit_Parameters">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/view-form-table.png</normaloff>:/icons/icons/view-form-table.png</iconset>
</property>
<property name="text">
<string>Edit Parameters</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCompile_Model">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/run-build.png</normaloff>:/icons/icons/run-build.png</iconset>
</property>
<property name="text">
<string>Compile Model</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionOpen_Simulation_Window">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/office-chart-line.png</normaloff>:/icons/icons/office-chart-line.png</iconset>
</property>
<property name="text">
<string>Open Simulation Window</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAbout">
<property name="text">
<string>About</string>
</property>
</action>
</widget> </widget>
<resources> <resources>
<include location="../../resources/resources.qrc"/> <include location="../../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

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ParamEditor</class>
<widget class="QWidget" name="ParamEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>541</width>
<height>241</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<layout class="QVBoxLayout" name="leftColumn">
<item>
<widget class="QListView" name="portList"/>
</item>
<item>
<layout class="QHBoxLayout" name="buttonRow">
<item>
<widget class="QPushButton" name="addParam">
<property name="text">
<string>Add Parameter</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeParam">
<property name="text">
<string>Remove Parameter</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="rightColumn">
<item>
<layout class="QFormLayout" name="basicForm">
<item row="0" column="0">
<widget class="QLabel" name="nameLabel">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="nameEdit"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="typeLabel">
<property name="text">
<string>Type:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="typeRow">
<item>
<widget class="QComboBox" name="typeComboBox"/>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="sizeLabel">
<property name="text">
<string>Size:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="sizeRow">
<item>
<widget class="QSpinBox" name="widthSize">
<property name="suffix">
<string> rows</string>
</property>
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="heightSize">
<property name="suffix">
<string> columns</string>
</property>
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Quantity:</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Unit:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="valueEdit"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="valueLabel">
<property name="text">
<string>Value:</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QFormLayout" name="descriptionForm">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="descriptionLabel">
<property name="text">
<string>Description:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPlainTextEdit" name="descriptionEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>20</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>60</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PlotSettingsDialog</class>
<widget class="QDialog" name="PlotSettingsDialog">
<property name="geometry"><rect><x>0</x><y>0</y><width>520</width><height>430</height></rect></property>
<property name="windowTitle"><string>Plot Settings</string></property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex"><number>0</number></property>
<widget class="QWidget" name="generalTab">
<attribute name="title"><string>General</string></attribute>
<layout class="QVBoxLayout" name="generalLayout">
<item><widget class="QGroupBox" name="labelsGroup"><property name="title"><string>Labels</string></property><layout class="QFormLayout" name="labelsForm">
<item row="0" column="0"><widget class="QLabel" name="titleLabel"><property name="text"><string>Title:</string></property></widget></item>
<item row="0" column="1"><widget class="QLineEdit" name="titleEdit"><property name="placeholderText"><string>Optional plot title</string></property></widget></item>
<item row="1" column="0"><widget class="QLabel" name="xLabel"><property name="text"><string>X-axis label:</string></property></widget></item>
<item row="1" column="1"><widget class="QLineEdit" name="xLabelEdit"><property name="placeholderText"><string>Automatic</string></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="yLabel"><property name="text"><string>Y-axis label:</string></property></widget></item>
<item row="2" column="1"><widget class="QLineEdit" name="yLabelEdit"><property name="placeholderText"><string>Optional</string></property></widget></item>
</layout></widget></item>
<item><spacer name="generalSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>40</height></size></property></spacer></item>
</layout>
</widget>
<widget class="QWidget" name="axesTab">
<attribute name="title"><string>Axes</string></attribute>
<layout class="QVBoxLayout" name="axesLayout">
<item><widget class="QGroupBox" name="xAxisGroup"><property name="title"><string>X axis</string></property><layout class="QGridLayout" name="xAxisLayout">
<item row="0" column="0"><widget class="QLabel" name="xScaleLabel"><property name="text"><string>Scale:</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="xScaleCombo"><item><property name="text"><string>Linear</string></property></item><item><property name="text"><string>Logarithmic</string></property></item></widget></item>
<item row="1" column="0" colspan="2"><widget class="QCheckBox" name="xAutoCheck"><property name="text"><string>Automatic limits</string></property><property name="checked"><bool>true</bool></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="xMinLabel"><property name="text"><string>Minimum:</string></property></widget></item>
<item row="2" column="1"><widget class="QDoubleSpinBox" name="xMinSpin"><property name="decimals"><number>8</number></property></widget></item>
<item row="2" column="2"><widget class="QLabel" name="xMaxLabel"><property name="text"><string>Maximum:</string></property></widget></item>
<item row="2" column="3"><widget class="QDoubleSpinBox" name="xMaxSpin"><property name="decimals"><number>8</number></property><property name="value"><double>1.000000000000000</double></property></widget></item>
</layout></widget></item>
<item><widget class="QGroupBox" name="yAxisGroup"><property name="title"><string>Y axis</string></property><layout class="QGridLayout" name="yAxisLayout">
<item row="0" column="0"><widget class="QLabel" name="yScaleLabel"><property name="text"><string>Scale:</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="yScaleCombo"><item><property name="text"><string>Linear</string></property></item><item><property name="text"><string>Logarithmic</string></property></item></widget></item>
<item row="1" column="0" colspan="2"><widget class="QCheckBox" name="yAutoCheck"><property name="text"><string>Automatic limits</string></property><property name="checked"><bool>true</bool></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="yMinLabel"><property name="text"><string>Minimum:</string></property></widget></item>
<item row="2" column="1"><widget class="QDoubleSpinBox" name="yMinSpin"><property name="decimals"><number>8</number></property></widget></item>
<item row="2" column="2"><widget class="QLabel" name="yMaxLabel"><property name="text"><string>Maximum:</string></property></widget></item>
<item row="2" column="3"><widget class="QDoubleSpinBox" name="yMaxSpin"><property name="decimals"><number>8</number></property><property name="value"><double>1.000000000000000</double></property></widget></item>
</layout></widget></item>
<item><spacer name="axesSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>20</height></size></property></spacer></item>
</layout>
</widget>
<widget class="QWidget" name="tracesTab">
<attribute name="title"><string>Traces</string></attribute>
<layout class="QVBoxLayout" name="tracesLayout">
<item><layout class="QFormLayout" name="traceSelectionForm">
<item row="0" column="0"><widget class="QLabel" name="traceLabel"><property name="text"><string>Trace:</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="traceCombo"/></item>
</layout></item>
<item><widget class="QGroupBox" name="traceGroup"><property name="title"><string>Appearance</string></property><layout class="QFormLayout" name="traceForm">
<item row="0" column="0" colspan="2"><widget class="QCheckBox" name="traceVisibleCheck"><property name="text"><string>Visible</string></property><property name="checked"><bool>true</bool></property></widget></item>
<item row="1" column="0"><widget class="QLabel" name="traceLegendLabel"><property name="text"><string>Legend label:</string></property></widget></item>
<item row="1" column="1"><widget class="QLineEdit" name="traceLegendEdit"><property name="placeholderText"><string>Signal name</string></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="traceColorLabel"><property name="text"><string>Line color:</string></property></widget></item>
<item row="2" column="1"><layout class="QHBoxLayout" name="traceColorLayout"><item><widget class="QPushButton" name="traceColorButton"><property name="text"><string>Automatic</string></property></widget></item><item><widget class="QPushButton" name="traceColorResetButton"><property name="text"><string>Reset</string></property></widget></item></layout></item>
<item row="3" column="0"><widget class="QLabel" name="traceLineStyleLabel"><property name="text"><string>Line style:</string></property></widget></item>
<item row="3" column="1"><widget class="QComboBox" name="traceLineStyleCombo"><item><property name="text"><string>Solid</string></property></item><item><property name="text"><string>Dashed</string></property></item><item><property name="text"><string>Dotted</string></property></item><item><property name="text"><string>Dash-dot</string></property></item><item><property name="text"><string>No line</string></property></item></widget></item>
<item row="4" column="0"><widget class="QLabel" name="traceLineWidthLabel"><property name="text"><string>Line width:</string></property></widget></item>
<item row="4" column="1"><widget class="QDoubleSpinBox" name="traceLineWidthSpin"><property name="minimum"><double>0.100000000000000</double></property><property name="maximum"><double>20.000000000000000</double></property><property name="singleStep"><double>0.250000000000000</double></property><property name="value"><double>1.500000000000000</double></property></widget></item>
<item row="5" column="0"><widget class="QLabel" name="traceMarkerLabel"><property name="text"><string>Marker:</string></property></widget></item>
<item row="5" column="1"><widget class="QComboBox" name="traceMarkerCombo"/></item>
<item row="6" column="0"><widget class="QLabel" name="traceMarkerSizeLabel"><property name="text"><string>Marker size:</string></property></widget></item>
<item row="6" column="1"><widget class="QDoubleSpinBox" name="traceMarkerSizeSpin"><property name="minimum"><double>0.100000000000000</double></property><property name="maximum"><double>50.000000000000000</double></property><property name="singleStep"><double>0.500000000000000</double></property><property name="value"><double>6.000000000000000</double></property></widget></item>
</layout></widget></item>
<item><spacer name="tracesSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>20</height></size></property></spacer></item>
</layout>
</widget>
<widget class="QWidget" name="appearanceTab">
<attribute name="title"><string>Grid &amp; Legend</string></attribute>
<layout class="QVBoxLayout" name="appearanceLayout">
<item><widget class="QGroupBox" name="gridGroup"><property name="title"><string>Grid</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><layout class="QFormLayout" name="gridForm">
<item row="0" column="0"><widget class="QLabel" name="gridAxisLabel"><property name="text"><string>Grid lines:</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="gridAxisCombo"><item><property name="text"><string>Both axes</string></property></item><item><property name="text"><string>X axis only</string></property></item><item><property name="text"><string>Y axis only</string></property></item></widget></item>
<item row="1" column="0"><widget class="QLabel" name="gridStyleLabel"><property name="text"><string>Line style:</string></property></widget></item>
<item row="1" column="1"><widget class="QComboBox" name="gridStyleCombo"><item><property name="text"><string>Solid</string></property></item><item><property name="text"><string>Dashed</string></property></item><item><property name="text"><string>Dotted</string></property></item><item><property name="text"><string>Dash-dot</string></property></item></widget></item>
<item row="2" column="0"><widget class="QLabel" name="gridOpacityLabel"><property name="text"><string>Opacity:</string></property></widget></item>
<item row="2" column="1"><widget class="QSlider" name="gridOpacitySlider"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="maximum"><number>100</number></property><property name="value"><number>50</number></property></widget></item>
</layout></widget></item>
<item><widget class="QGroupBox" name="legendGroup"><property name="title"><string>Legend</string></property><property name="checkable"><bool>true</bool></property><property name="checked"><bool>true</bool></property><layout class="QFormLayout" name="legendForm">
<item row="0" column="0"><widget class="QLabel" name="legendLocationLabel"><property name="text"><string>Position:</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="legendLocationCombo"/></item>
</layout></widget></item>
<item><spacer name="appearanceSpacer"><property name="orientation"><enum>Qt::Orientation::Vertical</enum></property><property name="sizeHint" stdset="0"><size><width>20</width><height>30</height></size></property></spacer></item>
</layout>
</widget>
</widget>
</item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
</layout>
</widget>
<resources/>
<connections>
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>PlotSettingsDialog</receiver><slot>accept()</slot><hints/></connection>
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>PlotSettingsDialog</receiver><slot>reject()</slot><hints/></connection>
</connections>
</ui>

View File

@@ -0,0 +1,274 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PortEditor</class>
<widget class="QWidget" name="PortEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>541</width>
<height>420</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<layout class="QVBoxLayout" name="leftColumn">
<item>
<widget class="QListView" name="portList"/>
</item>
<item>
<layout class="QHBoxLayout" name="buttonRow">
<item>
<widget class="QPushButton" name="addPort">
<property name="text">
<string>Add Port</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removePort">
<property name="text">
<string>Remove Port</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="rightColumn">
<item>
<layout class="QFormLayout" name="basicForm">
<item row="0" column="0">
<widget class="QLabel" name="nameLabel">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="nameEdit"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="typeLabel">
<property name="text">
<string>Type:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="typeRow">
<item>
<widget class="QRadioButton" name="typeSignal">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="typeBond">
<property name="text">
<string>Power Bond</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="orientationLabel">
<property name="text">
<string>Orientation:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="orientationRow">
<item>
<widget class="QRadioButton" name="inputOrientation">
<property name="text">
<string>Input</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="outputOrientation">
<property name="text">
<string>Output</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="sizeLabel">
<property name="text">
<string>Size</string>
</property>
</widget>
</item>
<item row="3" column="1">
<layout class="QHBoxLayout" name="sizeRow">
<item>
<widget class="QSpinBox" name="widthSize">
<property name="suffix">
<string> rows</string>
</property>
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="heightSize">
<property name="suffix">
<string> columns</string>
</property>
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="domainLabel">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QCheckBox" name="multiplicityCheckBox">
<property name="text">
<string>Allow multiple connections</string>
</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>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QFormLayout" name="signalOptions">
<item row="0" column="0">
<widget class="QLabel" name="signalTypeLabel">
<property name="text">
<string>Signal Type:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="signalTypeComboBox"/>
</item>
</layout>
</item>
<item>
<layout class="QFormLayout" name="bondOptions">
<item row="0" column="0">
<widget class="QLabel" name="domainLabel_2">
<property name="text">
<string>Domain:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="domainComboBox"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="causalityLabel">
<property name="text">
<string>Causality:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="causalityComboBox"/>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QFormLayout" name="descriptionForm">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="descriptionLabel">
<property name="text">
<string>Description:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPlainTextEdit" name="descriptionEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>20</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>60</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

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> <item>
<widget class="QTabWidget" name="tabWidget"> <widget class="QTabWidget" name="tabWidget">
<property name="currentIndex"> <property name="currentIndex">
<number>0</number> <number>1</number>
</property> </property>
<widget class="QWidget" name="General"> <widget class="QWidget" name="General">
<attribute name="title"> <attribute name="title">
@@ -55,7 +55,27 @@
</property> </property>
</widget> </widget>
</item> </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"> <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"> <spacer name="verticalSpacer">
<property name="orientation"> <property name="orientation">
<enum>Qt::Orientation::Vertical</enum> <enum>Qt::Orientation::Vertical</enum>
@@ -70,6 +90,34 @@
</item> </item>
</layout> </layout>
</widget> </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> </widget>
</item> </item>
<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

@@ -0,0 +1,258 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>562</width>
<height>451</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="simulationListLayout">
<item>
<widget class="QListWidget" name="simulationList"/>
</item>
<item>
<layout class="QHBoxLayout" name="simulationButtonLayout">
<item>
<widget class="QPushButton" name="addSimulationButton">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeSimulationButton">
<property name="text">
<string>Remove</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="frame">
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QFormLayout" name="basicForm">
<item row="0" column="0">
<widget class="QLabel" name="nameLabel">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="componentLabel">
<property name="text">
<string>Component:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="nameEdit"/>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="componentCompoBox"/>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QFormLayout" name="timeForm">
<item row="0" column="0">
<widget class="QLabel" name="startTimeLabel">
<property name="text">
<string>Start time:</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="simLengthLabel">
<property name="text">
<string>Simulation length:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QDoubleSpinBox" name="startTimeSpinBox">
<property name="suffix">
<string> s</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="simLengthSpinBox">
<property name="suffix">
<string> s</string>
</property>
</widget>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QRadioButton" name="stepSizeButton">
<property name="text">
<string>Step size</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="nrOfStepsButton">
<property name="text">
<string>Number of steps</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="stepSizeLabel">
<property name="text">
<string>Step size:</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="stepLabel">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="stepSizeSpinBox">
<property name="suffix">
<string> s</string>
</property>
<property name="decimals">
<number>4</number>
</property>
<property name="value">
<double>0.001000000000000</double>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QSpinBox" name="nrOfStepsSpinBox"/>
</item>
<item row="4" column="0">
<widget class="QLabel" name="nrOfStepsLabel">
<property name="text">
<string>Number of steps:</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QComboBox" name="simulationMethodComboBox"/>
</item>
<item row="5" column="0">
<widget class="QLabel" name="simMethodLabel">
<property name="text">
<string>Simulation method:</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QFormLayout" name="dasslForm">
<item row="0" column="1">
<widget class="QLineEdit" name="toleranceEdit">
<property name="text">
<string>1e-06</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="toleranceLabel">
<property name="text">
<string>Tolerance:</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,316 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SimulationWindow</class>
<widget class="QMainWindow" name="SimulationWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTabWidget" name="resultsTabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab">
<attribute name="title">
<string>Tab 1</string>
</attribute>
</widget>
</widget>
</item>
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>19</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="actionNew_Simulation_Run"/>
<addaction name="actionOpen_Simulation_Run"/>
<addaction name="actionSave_Simulation_Run"/>
</widget>
<widget class="QMenu" name="menuSimulation">
<property name="title">
<string>Simulation</string>
</property>
<addaction name="actionRestart_Simulation"/>
<addaction name="actionRun_Simulation"/>
<addaction name="actionStop_Simulation"/>
<addaction name="separator"/>
<addaction name="actionSimulation_Options"/>
</widget>
<widget class="QMenu" name="menuEdit">
<property name="title">
<string>Edit</string>
</property>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="separator"/>
<addaction name="actionSettings"/>
</widget>
<widget class="QMenu" name="menuTools">
<property name="title">
<string>Tools</string>
</property>
</widget>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="actionAbout"/>
<addaction name="actionAbout_QT"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuEdit"/>
<addaction name="menuSimulation"/>
<addaction name="menuTools"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QToolBar" name="fileToolbar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionNew_Simulation_Run"/>
<addaction name="actionOpen_Simulation_Run"/>
<addaction name="actionSave_Simulation_Run"/>
</widget>
<widget class="QToolBar" name="actionToolbar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
</widget>
<widget class="QToolBar" name="simToolbar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionRestart_Simulation"/>
<addaction name="actionRun_Simulation"/>
<addaction name="actionStop_Simulation"/>
<addaction name="actionSimulation_Options"/>
</widget>
<widget class="QDockWidget" name="logDockWidget">
<property name="windowTitle">
<string>Log</string>
</property>
<attribute name="dockWidgetArea">
<number>8</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QListView" name="listView"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="simulationTreeDock">
<property name="windowTitle">
<string>Signals</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QTreeWidget" name="simulationTree">
<attribute name="headerVisible">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string notr="true">1</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="actionRun_Simulation">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/media-playback-start.png</normaloff>:/icons/icons/media-playback-start.png</iconset>
</property>
<property name="text">
<string>Run Simulation</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionStop_Simulation">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/media-playback-stop.png</normaloff>:/icons/icons/media-playback-stop.png</iconset>
</property>
<property name="text">
<string>Stop Simulation</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionRestart_Simulation">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/media-skip-backward.png</normaloff>:/icons/icons/media-skip-backward.png</iconset>
</property>
<property name="text">
<string>Restart Simulation</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSimulation_Options">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/configure.png</normaloff>:/icons/icons/configure.png</iconset>
</property>
<property name="text">
<string>Simulation Options</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionNew_Simulation_Run">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-new.png</normaloff>:/icons/icons/document-new.png</iconset>
</property>
<property name="text">
<string>New Simulation Run</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionOpen_Simulation_Run">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-open.png</normaloff>:/icons/icons/document-open.png</iconset>
</property>
<property name="text">
<string>Open Simulation Run</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSave_Simulation_Run">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-save.png</normaloff>:/icons/icons/document-save.png</iconset>
</property>
<property name="text">
<string>Save Simulation Run</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionUndo">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
</property>
<property name="text">
<string>Undo</string>
</property>
<property name="shortcut">
<string>Ctrl+Z</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionRedo">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
</property>
<property name="text">
<string>Redo</string>
</property>
<property name="shortcut">
<string>Ctrl+Y</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSettings">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/preferences-system.png</normaloff>:/icons/icons/preferences-system.png</iconset>
</property>
<property name="text">
<string>Settings</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAbout">
<property name="text">
<string>About</string>
</property>
</action>
<action name="actionAbout_QT">
<property name="text">
<string>About QT</string>
</property>
</action>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>
</resources>
<connections/>
</ui>

138
src/bedit_gui/utils/icon.py Normal file
View File

@@ -0,0 +1,138 @@
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, QRegion
from bedit_core.models import Port, PortID, SignalDirection
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:
points: list[tuple[float, float]] = []
for shape in icon.shapes.values():
x, y = shape.pos
points.append((x, y))
if isinstance(shape, (Rectangle, Ellipse, Text)):
points.append((x + shape.width, y + shape.height))
elif isinstance(shape, Line):
points.append(shape.end)
for x, y in icon.port_positions.values():
points.append((x, y))
points.append((x + PORT_SIZE, y + PORT_SIZE))
if not points:
return QRectF()
left = min(point[0] for point in points)
top = min(point[1] for point in points)
right = max(point[0] for point in points)
bottom = max(point[1] for point in points)
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)
bounds = get_bounding_box(icon)
if not icon.shapes and not icon.port_positions:
return QIcon(pixmap)
scale = _render_scale(bounds, size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(size.width() / 2, size.height() / 2)
painter.scale(scale, scale)
painter.translate(-bounds.center())
for shape in sorted(icon.shapes.values(), key=lambda item: item.layer):
if isinstance(shape, Rectangle):
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)))
font.setBold(shape.bold)
font.setItalic(shape.italic)
painter.setFont(font)
painter.setPen(_color(shape.color))
painter.drawText(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, shape.text)
elif isinstance(shape, Line):
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
painter.drawLine(shape.pos[0], shape.pos[1], shape.end[0], shape.end[1])
if render_ports:
painter.setPen(QPen(QColor("#000000")))
for port_id, position in icon.port_positions.items():
port = ports.get(port_id)
if port is None:
continue
color = QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff")
painter.setBrush(QBrush(color))
painter.drawRect(position[0], position[1], PORT_SIZE, PORT_SIZE)
painter.end()
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:
return QPen(Qt.PenStyle.NoPen)
return QPen(_color(color), thickness, styles[line_type])
def _color(value: str) -> QColor:
color = value.removeprefix("#")
if len(color) == 8:
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
return QColor(value)

View File

@@ -0,0 +1,4 @@
"""Independent versions for the BEdit desktop applications."""
BEDIT_VERSION = "0.2.0"
BESIM_VERSION = "0.1.0"

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QColorDialog, QPushButton, QWidget
class ColorButton(QPushButton):
def __init__(self, color: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("colorButton")
self._color = self._from_rgba(color)
self.clicked.connect(self._select_color)
self._update_display()
def color(self) -> str:
return f"#{self._color.red():02x}{self._color.green():02x}{self._color.blue():02x}{self._color.alpha():02x}"
def set_color(self, color: str) -> None:
self._color = self._from_rgba(color)
self._update_display()
def _select_color(self) -> None:
parent = self.window()
dialog = QColorDialog(self._color, parent if parent is not self else None)
dialog.setOption(QColorDialog.ColorDialogOption.ShowAlphaChannel)
if dialog.exec() == QColorDialog.DialogCode.Accepted:
self._color = dialog.selectedColor()
self._update_display()
def _update_display(self) -> None:
self.setText(self.color())
foreground = "#000000" if self._color.lightness() > 127 or self._color.alpha() < 128 else "#ffffff"
self.setStyleSheet(f"QPushButton#colorButton {{ background-color: rgba({self._color.red()}, {self._color.green()}, {self._color.blue()}, {self._color.alpha()}); color: {foreground}; }}")
@staticmethod
def _from_rgba(value: str) -> QColor:
color = value.removeprefix("#")
if len(color) == 6:
color += "ff"
if len(color) == 8:
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
return QColor(value)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QVBoxLayout,
QWidget,
)
from bedit_core.models import Port, PortID
from bedit_gui.models import PortMetadata
from bedit_gui.views.port_editor_widget import PortEditorWidget
class InterfaceEditorDialog(QDialog):
"""Modal wrapper around the reusable port editor widget."""
def __init__(
self,
ports: dict[PortID, Port],
port_metadata: dict[PortID, PortMetadata] | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("Edit Interface")
self.resize(700, 500)
layout = QVBoxLayout(self)
self.editor = PortEditorWidget(self)
self.editor.set_ports(ports, port_metadata)
layout.addWidget(self.editor)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def ports(self) -> dict[PortID, Port]:
return self.editor.ports()
def port_metadata(self) -> dict[PortID, PortMetadata]:
return self.editor.port_metadata()

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QWidget
from bedit_core.models import ParameterID, Parameter
from bedit_gui.views.param_editor_widget import ParamEditorWidget
class ParamEditorDialog(QDialog):
"""Modal wrapper around the reusable param editor widget."""
def __init__(
self,
params: dict[ParameterID, Parameter],
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("Edit Parameters")
self.resize(550, 280)
layout = QVBoxLayout(self)
self.editor = ParamEditorWidget(self)
self.editor.set_params(params)
layout.addWidget(self.editor)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def params(self) -> dict[ParameterID, Parameter]:
return self.editor.params()

View File

@@ -0,0 +1,139 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QColorDialog, QDialog, QMessageBox, QWidget
from bedit_gui.simulation_models import SimulationPlotSettings, SimulationTraceSettings
from bedit_gui.ui.generated.ui_plot_settings import Ui_PlotSettingsDialog
class PlotSettingsDialog(QDialog):
def __init__(self, settings: SimulationPlotSettings, signals: list[str], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.ui = Ui_PlotSettingsDialog()
self.ui.setupUi(self)
self._trace_settings = deepcopy(settings.traces)
self._current_trace: str | None = None
self._trace_color = ""
self._configure_controls()
self._load(settings)
self.ui.traceCombo.addItems(signals)
self.ui.traceCombo.currentIndexChanged.connect(self._trace_changed)
self.ui.traceColorButton.clicked.connect(self._choose_trace_color)
self.ui.traceColorResetButton.clicked.connect(self._reset_trace_color)
self.ui.xAutoCheck.toggled.connect(self._update_limit_controls)
self.ui.yAutoCheck.toggled.connect(self._update_limit_controls)
self._update_limit_controls()
self._trace_changed(self.ui.traceCombo.currentIndex())
def settings(self) -> SimulationPlotSettings:
self._store_trace()
return SimulationPlotSettings(
title=self.ui.titleEdit.text().strip(), x_label=self.ui.xLabelEdit.text().strip(), y_label=self.ui.yLabelEdit.text().strip(),
x_scale=self.ui.xScaleCombo.currentData(), y_scale=self.ui.yScaleCombo.currentData(),
x_auto=self.ui.xAutoCheck.isChecked(), y_auto=self.ui.yAutoCheck.isChecked(),
x_min=self.ui.xMinSpin.value(), x_max=self.ui.xMaxSpin.value(), y_min=self.ui.yMinSpin.value(), y_max=self.ui.yMaxSpin.value(),
grid_visible=self.ui.gridGroup.isChecked(), grid_axis=self.ui.gridAxisCombo.currentData(), grid_style=self.ui.gridStyleCombo.currentData(), grid_alpha=self.ui.gridOpacitySlider.value() / 100.0,
legend_visible=self.ui.legendGroup.isChecked(), legend_location=self.ui.legendLocationCombo.currentData(),
traces=deepcopy(self._trace_settings),
)
def accept(self) -> None:
settings = self.settings()
if not settings.x_auto and settings.x_min >= settings.x_max:
QMessageBox.warning(self, "Invalid x-axis limits", "The x-axis maximum must be greater than its minimum.")
return
if not settings.y_auto and settings.y_min >= settings.y_max:
QMessageBox.warning(self, "Invalid y-axis limits", "The y-axis maximum must be greater than its minimum.")
return
if settings.x_scale == "log" and not settings.x_auto and settings.x_min <= 0:
QMessageBox.warning(self, "Invalid x-axis limits", "A logarithmic x axis requires a positive minimum.")
return
if settings.y_scale == "log" and not settings.y_auto and settings.y_min <= 0:
QMessageBox.warning(self, "Invalid y-axis limits", "A logarithmic y axis requires a positive minimum.")
return
super().accept()
def _configure_controls(self) -> None:
for combo in (self.ui.xScaleCombo, self.ui.yScaleCombo):
combo.setItemData(0, "linear")
combo.setItemData(1, "log")
for label, value in (("Both axes", "both"), ("X axis only", "x"), ("Y axis only", "y")):
self.ui.gridAxisCombo.setItemData(self.ui.gridAxisCombo.findText(label), value)
for index, value in enumerate(("-", "--", ":", "-.")):
self.ui.gridStyleCombo.setItemData(index, value)
self.ui.traceLineStyleCombo.setItemData(index, value)
self.ui.traceLineStyleCombo.setItemData(4, "None")
for label, value in (("None", ""), ("Point", "."), ("Circle", "o"), ("Square", "s"), ("Triangle up", "^"), ("Triangle down", "v"), ("Diamond", "D"), ("Plus", "+"), ("Cross", "x"), ("Star", "*")):
self.ui.traceMarkerCombo.addItem(label, value)
for label, value in (("Automatic", "best"), ("Upper right", "upper right"), ("Upper left", "upper left"), ("Lower right", "lower right"), ("Lower left", "lower left"), ("Center right", "center right"), ("Center left", "center left"), ("Upper center", "upper center"), ("Lower center", "lower center"), ("Center", "center")):
self.ui.legendLocationCombo.addItem(label, value)
for spin in (self.ui.xMinSpin, self.ui.xMaxSpin, self.ui.yMinSpin, self.ui.yMaxSpin):
spin.setRange(-1e100, 1e100)
def _load(self, settings: SimulationPlotSettings) -> None:
self.ui.titleEdit.setText(settings.title)
self.ui.xLabelEdit.setText(settings.x_label)
self.ui.yLabelEdit.setText(settings.y_label)
self.ui.xScaleCombo.setCurrentIndex(self.ui.xScaleCombo.findData(settings.x_scale))
self.ui.yScaleCombo.setCurrentIndex(self.ui.yScaleCombo.findData(settings.y_scale))
self.ui.xAutoCheck.setChecked(settings.x_auto)
self.ui.yAutoCheck.setChecked(settings.y_auto)
self.ui.xMinSpin.setValue(settings.x_min)
self.ui.xMaxSpin.setValue(settings.x_max)
self.ui.yMinSpin.setValue(settings.y_min)
self.ui.yMaxSpin.setValue(settings.y_max)
self.ui.gridGroup.setChecked(settings.grid_visible)
self.ui.gridAxisCombo.setCurrentIndex(self.ui.gridAxisCombo.findData(settings.grid_axis))
self.ui.gridStyleCombo.setCurrentIndex(self.ui.gridStyleCombo.findData(settings.grid_style))
self.ui.gridOpacitySlider.setValue(round(settings.grid_alpha * 100))
self.ui.legendGroup.setChecked(settings.legend_visible)
self.ui.legendLocationCombo.setCurrentIndex(self.ui.legendLocationCombo.findData(settings.legend_location))
def _update_limit_controls(self) -> None:
for widget in (self.ui.xMinLabel, self.ui.xMinSpin, self.ui.xMaxLabel, self.ui.xMaxSpin):
widget.setEnabled(not self.ui.xAutoCheck.isChecked())
for widget in (self.ui.yMinLabel, self.ui.yMinSpin, self.ui.yMaxLabel, self.ui.yMaxSpin):
widget.setEnabled(not self.ui.yAutoCheck.isChecked())
def _trace_changed(self, index: int) -> None:
self._store_trace()
self._current_trace = self.ui.traceCombo.itemText(index) if index >= 0 else None
self.ui.traceGroup.setEnabled(self._current_trace is not None)
trace = self._trace_settings.get(self._current_trace, SimulationTraceSettings())
self.ui.traceVisibleCheck.setChecked(trace.visible)
self.ui.traceLegendEdit.setText(trace.label)
self.ui.traceLineStyleCombo.setCurrentIndex(self.ui.traceLineStyleCombo.findData(trace.line_style))
self.ui.traceLineWidthSpin.setValue(trace.line_width)
self.ui.traceMarkerCombo.setCurrentIndex(self.ui.traceMarkerCombo.findData(trace.marker))
self.ui.traceMarkerSizeSpin.setValue(trace.marker_size)
self._set_trace_color(trace.color)
def _store_trace(self) -> None:
if self._current_trace is None:
return
trace = SimulationTraceSettings(
visible=self.ui.traceVisibleCheck.isChecked(), label=self.ui.traceLegendEdit.text().strip(), color=self._trace_color,
line_style=self.ui.traceLineStyleCombo.currentData(), line_width=self.ui.traceLineWidthSpin.value(),
marker=self.ui.traceMarkerCombo.currentData(), marker_size=self.ui.traceMarkerSizeSpin.value(),
)
if trace == SimulationTraceSettings():
self._trace_settings.pop(self._current_trace, None)
else:
self._trace_settings[self._current_trace] = trace
def _choose_trace_color(self) -> None:
initial = QColor(self._trace_color) if self._trace_color else QColor("black")
color = QColorDialog.getColor(initial, self, "Select trace color")
if color.isValid():
self._set_trace_color(color.name(QColor.NameFormat.HexRgb))
def _reset_trace_color(self) -> None:
self._set_trace_color("")
def _set_trace_color(self, color: str) -> None:
self._trace_color = color
self.ui.traceColorButton.setText(color or "Automatic")
self.ui.traceColorButton.setStyleSheet(f"background-color: {color};" if color else "")

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import logging 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 from bedit_gui.ui.generated.ui_settings_dialog import Ui_Settings
@@ -20,6 +23,8 @@ class SettingsDialog(QDialog):
def __init__( def __init__(
self, self,
log_level: int, log_level: int,
snap_to_grid_size: int,
library_paths: list[str],
parent: QWidget | None = None, parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
@@ -35,7 +40,46 @@ class SettingsDialog(QDialog):
self.ui.logLevel.setCurrentIndex( self.ui.logLevel.setCurrentIndex(
selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO) 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 @property
def log_level(self) -> int: def log_level(self) -> int:
return int(self.ui.logLevel.currentData()) 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

@@ -0,0 +1,215 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtCore import Qt
from PySide6.QtGui import QDoubleValidator
from PySide6.QtWidgets import QDialog, QLayout, QListWidgetItem, QMessageBox, QWidget
from bedit_core.models import ComponentID
from bedit_gui.models import Simulation, SimulationDatabase, SimulationID, SimulationMethod
from bedit_gui.ui.generated.ui_simulation_settings import Ui_Dialog
class SimulationSettingsDialog(QDialog):
"""Editor for the detached simulation database of a document."""
def __init__(self, database: SimulationDatabase, components: list[tuple[ComponentID, str]], parent: QWidget | None = None, *, show_simulation_list: bool = True) -> None:
super().__init__(parent)
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.setWindowTitle("Simulation Settings")
self._database = deepcopy(database)
self._simulation_ids = list(self._database.simulations)
self._components = components
self._loading = False
if not show_simulation_list:
self._set_layout_visible(self.ui.simulationListLayout, False)
self.ui.startTimeSpinBox.setRange(-1e12, 1e12)
self.ui.simLengthSpinBox.setRange(0.0, 1e12)
self.ui.stepSizeSpinBox.setRange(1e-9, 1e12)
self.ui.nrOfStepsSpinBox.setRange(1, 1_000_000_000)
tolerance_validator = QDoubleValidator(0.0, 1e12, 16, self)
tolerance_validator.setNotation(QDoubleValidator.Notation.ScientificNotation)
self.ui.toleranceEdit.setValidator(tolerance_validator)
for component_id, path in components:
self.ui.componentCompoBox.addItem(path, str(component_id))
for method in SimulationMethod:
self.ui.simulationMethodComboBox.addItem(method.value.upper(), method.value)
self.ui.simulationList.currentRowChanged.connect(self._selection_changed)
self.ui.addSimulationButton.clicked.connect(self._add_simulation)
self.ui.removeSimulationButton.clicked.connect(self._remove_simulation)
self.ui.nameEdit.textEdited.connect(self._form_changed)
self.ui.componentCompoBox.currentIndexChanged.connect(self._form_changed)
self.ui.startTimeSpinBox.valueChanged.connect(self._form_changed)
self.ui.simLengthSpinBox.valueChanged.connect(self._form_changed)
self.ui.stepSizeButton.toggled.connect(self._form_changed)
self.ui.nrOfStepsButton.toggled.connect(self._form_changed)
self.ui.stepSizeSpinBox.valueChanged.connect(self._form_changed)
self.ui.nrOfStepsSpinBox.valueChanged.connect(self._form_changed)
self.ui.simulationMethodComboBox.currentIndexChanged.connect(self._form_changed)
self.ui.toleranceEdit.textEdited.connect(self._form_changed)
self._rebuild_list(self._database.active_simulation)
def database(self) -> SimulationDatabase:
return deepcopy(self._database)
def accept(self) -> None:
for simulation in self._database.simulations.values():
if not simulation.name.strip():
QMessageBox.warning(self, "Invalid simulation", "Every simulation must have a name.")
return
if simulation.component not in {component_id for component_id, _ in self._components}:
QMessageBox.warning(self, "Invalid simulation", f"Select an existing component for {simulation.name}.")
return
if simulation.dassl_tolerance <= 0:
QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.")
return
if simulation.duration <= 0:
QMessageBox.warning(self, "Invalid simulation", "The simulation length must be greater than zero.")
return
simulation.name = simulation.name.strip()
self._database.active_simulation = self._simulation_id()
super().accept()
def _rebuild_list(self, selected_id: SimulationID | None = None) -> None:
self.ui.simulationList.clear()
for simulation_id in self._simulation_ids:
item = QListWidgetItem(self._database.simulations[simulation_id].name)
item.setData(Qt.ItemDataRole.UserRole, simulation_id)
self.ui.simulationList.addItem(item)
if self._simulation_ids:
if selected_id not in self._database.simulations:
selected_id = self._simulation_ids[0]
self.ui.simulationList.setCurrentRow(self._simulation_ids.index(selected_id))
else:
self._set_editor_enabled(False)
def _selection_changed(self, row: int) -> None:
simulation_id = self._simulation_id(row)
self._set_editor_enabled(simulation_id is not None)
self.ui.removeSimulationButton.setEnabled(simulation_id is not None)
if simulation_id is not None:
self._load_simulation(self._database.simulations[simulation_id])
def _simulation_id(self, row: int | None = None) -> SimulationID | None:
if row is None:
row = self.ui.simulationList.currentRow()
if row < 0 or row >= len(self._simulation_ids):
return None
return self._simulation_ids[row]
def _load_simulation(self, simulation: Simulation) -> None:
self._loading = True
self.ui.nameEdit.setText(simulation.name)
component_index = self.ui.componentCompoBox.findData(str(simulation.component))
if component_index < 0:
self.ui.componentCompoBox.addItem(f"Missing component ({simulation.component})", str(simulation.component))
component_index = self.ui.componentCompoBox.count() - 1
self.ui.componentCompoBox.setCurrentIndex(component_index)
self.ui.startTimeSpinBox.setValue(simulation.start_time)
self.ui.simLengthSpinBox.setValue(simulation.duration)
self.ui.stepSizeButton.setChecked(simulation.use_timed_steps)
self.ui.nrOfStepsButton.setChecked(not simulation.use_timed_steps)
self.ui.stepSizeSpinBox.setValue(simulation.step_size)
self.ui.nrOfStepsSpinBox.setValue(simulation.number_of_steps)
self.ui.simulationMethodComboBox.setCurrentIndex(self.ui.simulationMethodComboBox.findData(simulation.method.value))
self.ui.toleranceEdit.setText(str(simulation.dassl_tolerance))
self._loading = False
self._update_step_inputs()
def _form_changed(self, *_args: object) -> None:
if self._loading:
return
simulation_id = self._simulation_id()
if simulation_id is None:
return
self._update_step_inputs()
simulation = self._database.simulations[simulation_id]
try:
tolerance = float(self.ui.toleranceEdit.text())
except ValueError:
tolerance = simulation.dassl_tolerance
component_data = self.ui.componentCompoBox.currentData()
component = ComponentID(component_data) if isinstance(component_data, str) and component_data else simulation.component
method_data = self.ui.simulationMethodComboBox.currentData()
method = SimulationMethod(method_data) if isinstance(method_data, str) else simulation.method
self._database.simulations[simulation_id] = Simulation(
component=component,
name=self.ui.nameEdit.text(),
start_time=self.ui.startTimeSpinBox.value(),
duration=self.ui.simLengthSpinBox.value(),
use_timed_steps=self.ui.stepSizeButton.isChecked(),
number_of_steps=self.ui.nrOfStepsSpinBox.value(),
step_size=self.ui.stepSizeSpinBox.value(),
method=method,
dassl_tolerance=tolerance,
)
self.ui.simulationList.item(self.ui.simulationList.currentRow()).setText(self.ui.nameEdit.text())
def _add_simulation(self) -> None:
if not self._components:
return
simulation_id = SimulationID()
simulation = Simulation(
component=self._components[0][0],
name=self._unique_name("Simulation"),
start_time=0.0,
duration=1.0,
use_timed_steps=False,
number_of_steps=500,
step_size=0.001,
method=SimulationMethod.DASSL,
dassl_tolerance=1e-6,
)
self._database.simulations[simulation_id] = simulation
self._simulation_ids.append(simulation_id)
self._rebuild_list(simulation_id)
def _remove_simulation(self) -> None:
simulation_id = self._simulation_id()
if simulation_id is None:
return
row = self._simulation_ids.index(simulation_id)
del self._database.simulations[simulation_id]
self._simulation_ids.remove(simulation_id)
if self._database.active_simulation == simulation_id:
self._database.active_simulation = None
selected = self._simulation_ids[min(row, len(self._simulation_ids) - 1)] if self._simulation_ids else None
self._rebuild_list(selected)
def _unique_name(self, base: str) -> str:
names = {simulation.name for simulation in self._database.simulations.values()}
if base not in names:
return base
index = 2
while f"{base} {index}" in names:
index += 1
return f"{base} {index}"
def _update_step_inputs(self) -> None:
timed = self.ui.stepSizeButton.isChecked()
self.ui.stepSizeSpinBox.setEnabled(timed)
self.ui.nrOfStepsSpinBox.setEnabled(not timed)
def _set_editor_enabled(self, enabled: bool) -> None:
self.ui.frame.setEnabled(enabled)
self.ui.removeSimulationButton.setEnabled(enabled)
self.ui.addSimulationButton.setEnabled(bool(self._components))
@classmethod
def _set_layout_visible(cls, layout: QLayout, visible: bool) -> None:
for index in range(layout.count()):
item = layout.itemAt(index)
widget = item.widget()
child_layout = item.layout()
if widget is not None:
widget.setVisible(visible)
elif child_layout is not None:
cls._set_layout_visible(child_layout, visible)

View File

@@ -0,0 +1,178 @@
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, 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
class EquationEditorWidget(QWidget):
"""Editor that keeps an equation component synchronized with its fields."""
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:
super().__init__(parent)
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)
self._edit_timer.setInterval(750)
self._edit_timer.setSingleShot(True)
self._edit_timer.timeout.connect(self.finish_text_edit)
self.param_editor = ParamEditorWidget(self)
self.port_editor = PortEditorWidget(self)
self._replace_placeholder(self.ui.paramEditor, self.param_editor)
self._replace_placeholder(self.ui.portEditor, self.port_editor)
self.ui.sidebarButton.clicked.connect(self.toggle_sidebar)
self.set_sidebar_visible(True)
for editor in self._text_editors():
editor.setUndoRedoEnabled(False)
editor.installEventFilter(self)
editor.textChanged.connect(self._text_changed)
self.param_editor.params_changed.connect(self._params_changed)
self.port_editor.ports_changed.connect(self._ports_changed)
self._set_editors_enabled(False)
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:
return self._component
def finish_text_edit(self) -> None:
self._edit_timer.stop()
self._edit_id += 1
def toggle_sidebar(self) -> None:
self.set_sidebar_visible(not self.param_editor.isVisibleTo(self))
def set_sidebar_visible(self, visible: bool) -> None:
changed = self.param_editor.isVisibleTo(self) != visible
self.param_editor.setVisible(visible)
self.port_editor.setVisible(visible)
self.ui.sidebarButton.setArrowType(Qt.ArrowType.RightArrow if visible else Qt.ArrowType.LeftArrow)
self.ui.sidebarButton.setToolTip("Hide parameter and port editors" if visible else "Show parameter and port editors")
if changed:
self.sidebar_visible_changed.emit(visible)
def refresh(self) -> None:
"""Reload the editors after the component was changed externally."""
self._loading = True
component = self._component
if component is None:
self.ui.declarationsTextEdit.clear()
self.ui.initialEquationsTextEdit.clear()
self.ui.equationsTextEdit.clear()
self.param_editor.set_params({})
self.port_editor.set_ports({})
else:
implementation = component.implementation
assert isinstance(implementation, EquationImplementation)
self.ui.declarationsTextEdit.setPlainText("\n".join(implementation.declarations))
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_metadata)
self._loading = False
self._set_editors_enabled(component is not None)
def refresh_text(self, section: str) -> None:
if self._component is None:
return
implementation = self._component.implementation
assert isinstance(implementation, EquationImplementation)
editor = self._editor_for_section(section)
text = "\n".join(getattr(implementation, section))
if editor.toPlainText() == text:
return
self._loading = True
editor.setPlainText(text)
self._loading = False
def _text_changed(self) -> None:
if self._loading or self._component is None:
return
editor = self.sender()
section = self._section_for_editor(editor)
self.equation_text_change_requested.emit(self._component, section, editor.toPlainText().splitlines(), self._edit_id)
self._edit_timer.start()
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched in self._text_editors() and event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
self.finish_text_edit()
return super().eventFilter(watched, event)
def _text_editors(self) -> tuple:
return (self.ui.declarationsTextEdit, self.ui.initialEquationsTextEdit, self.ui.equationsTextEdit)
def _section_for_editor(self, editor: QObject) -> str:
if editor is self.ui.declarationsTextEdit:
return "declarations"
if editor is self.ui.initialEquationsTextEdit:
return "initial_equations"
return "equations"
def _editor_for_section(self, section: str):
return {
"declarations": self.ui.declarationsTextEdit,
"initial_equations": self.ui.initialEquationsTextEdit,
"equations": self.ui.equationsTextEdit,
}[section]
def _params_changed(self) -> None:
if self._loading or self._component is None:
return
self._component.parameters = self.param_editor.params()
self.component_changed.emit(self._component)
def _ports_changed(self) -> None:
if self._loading or self._component is None:
return
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)
placeholder.hide()
placeholder.deleteLater()
def _set_editors_enabled(self, enabled: bool) -> None:
self.ui.declarationsTextEdit.setEnabled(enabled)
self.ui.initialEquationsTextEdit.setEnabled(enabled)
self.ui.equationsTextEdit.setEnabled(enabled)
self.param_editor.setEnabled(enabled)
self.port_editor.setEnabled(enabled)

View File

@@ -0,0 +1,853 @@
from __future__ import annotations
import json
from enum import Enum
from itertools import pairwise
from math import hypot
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 = 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):
"""Graph canvas with a lightweight dotted-line grid."""
def drawBackground(self, painter: QPainter, rect: QRectF) -> None:
painter.fillRect(rect, QColor("white"))
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, scene_rect.top(), x, scene_rect.bottom())
x += GRID_SPACING
y = top
while y <= rect.bottom():
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):
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)
@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:
self._zoom(event)
return True
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
self._scroll_horizontally(event)
return True
return super().eventFilter(watched, event)
def _zoom(self, event: QWheelEvent) -> None:
delta = event.angleDelta().y() or event.pixelDelta().y()
if not delta:
return
current_zoom = self.ui.graphicsView.transform().m11()
requested_zoom = current_zoom * ZOOM_STEP ** (delta / 120)
target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, requested_zoom))
self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom)
def _scroll_horizontally(self, event: QWheelEvent) -> None:
scrollbar = self.ui.graphicsView.horizontalScrollBar()
pixel_delta = event.pixelDelta().y()
distance = pixel_delta if pixel_delta else event.angleDelta().y() / 120 * scrollbar.singleStep() * 3
scrollbar.setValue(scrollbar.value() - round(distance))

View File

@@ -0,0 +1,354 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtCore import QEvent, QObject, Qt, Signal
from PySide6.QtGui import QKeySequence, QPainter, QShortcut, QUndoCommand, QUndoStack, QWheelEvent, QShowEvent
from PySide6.QtWidgets import QDialog, QFileDialog, QGraphicsView, QMainWindow, QMessageBox, QWidget
from bedit_core.models import Port, PortID, SignalDirection
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 EllipseCreationTool, IconGraphicsScene, LineCreationTool, RectangleCreationTool, TextCreationTool
from bedit_gui.views.shape_options_dialog import ShapeOptionsDialog
logger = get_logger(__name__)
class ChangeIconDraftCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, icon: Icon, text: str) -> None:
super().__init__(text)
self.editor = editor
self.old_icon = editor.icon()
self.new_icon = deepcopy(icon)
def redo(self) -> None:
self.editor._set_icon(self.new_icon)
def undo(self) -> None:
self.editor._set_icon(self.old_icon)
class AddShapeCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, shape: Shape) -> None:
super().__init__(f"Add {shape.type}")
self.editor = editor
self.shape_id = shape_id
self.shape = deepcopy(shape)
def redo(self) -> None:
self.editor._add_shape(self.shape_id, self.shape)
def undo(self) -> None:
self.editor._remove_shape(self.shape_id)
class ChangeShapeCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None:
super().__init__(f"Change {new_shape.type}")
self.editor = editor
self.shape_id = shape_id
self.old_shape = deepcopy(old_shape)
self.new_shape = deepcopy(new_shape)
def redo(self) -> None:
self.editor._change_shape(self.shape_id, self.new_shape)
def undo(self) -> None:
self.editor._change_shape(self.shape_id, self.old_shape)
class DeleteShapesCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, shapes: dict[ShapeID, Shape]) -> None:
text = "Delete shape" if len(shapes) == 1 else "Delete shapes"
super().__init__(text)
self.editor = editor
self.shapes = deepcopy(shapes)
def redo(self) -> None:
self.editor._remove_shapes(self.shapes)
def undo(self) -> None:
self.editor._restore_shapes(self.shapes)
class MovePortCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None:
super().__init__("Move port")
self.editor = editor
self.port_id = port_id
self.old_position = old_position
self.new_position = new_position
def redo(self) -> None:
self.editor._move_port(self.port_id, self.new_position)
def undo(self) -> None:
self.editor._move_port(self.port_id, self.old_position)
class IconEditorWindow(QMainWindow):
"""Independent icon editing session with its own undo stack."""
saved = Signal(object)
icon_changed = Signal(object)
zoom_step = 1.2
minimum_zoom = 0.1
maximum_zoom = 10.0
def __init__(self, icon: Icon, ports: dict[PortID, Port], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.ui = Ui_iconEditor()
self.ui.setupUi(self)
self.setWindowTitle("Icon Editor")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self._icon = deepcopy(icon)
self._ports = deepcopy(ports)
self.scene = IconGraphicsScene(self)
self.scene.set_ports(self._ports)
self._ensure_port_positions(self._ports)
self.scene.set_icon(self._icon)
self.scene.port_moved.connect(self._port_moved)
self.scene.shape_created.connect(self._shape_created)
self.scene.shape_changed.connect(self._shape_changed)
self.scene.shape_options_requested.connect(self._show_shape_options)
self.scene.tool_active_changed.connect(self._tool_active_changed)
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.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)
self.ui.actionAdd_Line.triggered.connect(self._start_line_tool)
self.cancel_tool_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
self.cancel_tool_shortcut.activated.connect(self.scene.cancel_creation_tool)
self.delete_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Delete), self)
self.delete_shortcut.activated.connect(self._delete_selected_shapes)
self.zoom_in_shortcut = QShortcut(QKeySequence("Ctrl++"), self)
self.zoom_in_alt_shortcut = QShortcut(QKeySequence("Ctrl+="), self)
self.zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+-"), self)
self.zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self)
self.zoom_in_shortcut.activated.connect(self.zoom_in)
self.zoom_in_alt_shortcut.activated.connect(self.zoom_in)
self.zoom_out_shortcut.activated.connect(self.zoom_out)
self.zoom_reset_shortcut.activated.connect(self.reset_zoom)
self.undo_stack = QUndoStack(self)
self.ui.actionUndo.triggered.connect(self.undo_stack.undo)
self.ui.actionRedo.triggered.connect(self.undo_stack.redo)
self.ui.actionSave.triggered.connect(self.save)
self.ui.actionSave_to_File.triggered.connect(self.save_to_file)
self.ui.actionOpen_from_File.triggered.connect(self.open_from_file)
self.ui.actionCancel.triggered.connect(self.close)
self.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
self.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
self.undo_stack.undoTextChanged.connect(self._update_undo_text)
self.undo_stack.redoTextChanged.connect(self._update_redo_text)
self.ui.actionUndo.setEnabled(False)
self.ui.actionRedo.setEnabled(False)
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self.fit_scene()
def icon(self) -> Icon:
return deepcopy(self._icon)
def apply_change(self, icon: Icon, text: str = "Edit icon") -> None:
self.undo_stack.push(ChangeIconDraftCommand(self, icon, text))
def save(self) -> None:
self.saved.emit(self.icon())
self.close()
def save_to_file(self) -> None:
file_name, _ = QFileDialog.getSaveFileName(self, "Save Icon", "", "JSON files (*.json)")
if not file_name:
return
path = file_name if file_name.lower().endswith(".json") else f"{file_name}.json"
try:
icon_files.save(self._icon, path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save icon to %s", path)
QMessageBox.critical(self, "Could not save icon", str(exc))
return
logger.info("Saved icon to: %s", path)
def open_from_file(self) -> None:
file_name, _ = QFileDialog.getOpenFileName(self, "Open Icon", "", "JSON files (*.json)")
if not file_name:
return
try:
icon = icon_files.load(file_name)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not open icon from %s", file_name)
QMessageBox.critical(self, "Could not open icon", str(exc))
return
self.apply_change(icon, "Load icon from file")
logger.info("Loaded icon from: %s", file_name)
def zoom_in(self) -> None:
self._zoom(self.zoom_step)
def zoom_out(self) -> None:
self._zoom(1 / self.zoom_step)
def reset_zoom(self) -> None:
self.fit_scene()
def fit_scene(self) -> None:
self.ui.graphicsView.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.ui.graphicsView.viewport() and isinstance(event, QWheelEvent):
if event.angleDelta().y() == 0:
return True
self.zoom_in() if event.angleDelta().y() > 0 else self.zoom_out()
event.accept()
return True
return super().eventFilter(watched, event)
def _zoom(self, factor: float) -> None:
current = self.ui.graphicsView.transform().m11()
target = max(self.minimum_zoom, min(self.maximum_zoom, current * factor))
if target != current:
factor = target / current
self.ui.graphicsView.scale(factor, factor)
def _set_icon(self, icon: Icon) -> None:
self._icon = deepcopy(icon)
self._ensure_port_positions(self._ports)
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _add_shape(self, shape_id: ShapeID, shape: Shape) -> None:
self._icon.shapes[shape_id] = deepcopy(shape)
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _remove_shape(self, shape_id: ShapeID) -> None:
self._icon.shapes.pop(shape_id, None)
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _remove_shapes(self, shapes: dict[ShapeID, Shape]) -> None:
for shape_id in shapes:
self._icon.shapes.pop(shape_id, None)
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _restore_shapes(self, shapes: dict[ShapeID, Shape]) -> None:
self._icon.shapes.update(deepcopy(shapes))
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _change_shape(self, shape_id: ShapeID, shape: Shape) -> None:
self._icon.shapes[shape_id] = deepcopy(shape)
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _move_port(self, port_id: PortID, position: tuple[int, int]) -> None:
self._icon.port_positions[port_id] = position
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _shape_created(self, shape: Shape) -> None:
self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape))
def _shape_changed(self, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None:
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape))
def _port_moved(self, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None:
self.undo_stack.push(MovePortCommand(self, port_id, old_position, new_position))
def _delete_selected_shapes(self) -> None:
shape_ids = self.scene.selected_shape_ids()
shapes = {shape_id: self._icon.shapes[shape_id] for shape_id in shape_ids}
if shapes:
self.undo_stack.push(DeleteShapesCommand(self, shapes))
def _show_shape_options(self, shape_id: ShapeID) -> None:
old_shape = self._icon.shapes.get(shape_id)
if old_shape is None:
return
dialog = ShapeOptionsDialog(old_shape, self.scene.sceneRect(), self)
if dialog.exec() == QDialog.DialogCode.Accepted:
new_shape = dialog.shape()
if new_shape != old_shape:
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape))
def _ensure_port_positions(self, ports: dict[PortID, Port]) -> None:
bounds = self.scene.sceneRect()
left = round(bounds.left())
top = round(bounds.top())
right = round(bounds.right() - 16)
bottom = round(bounds.bottom() - 16)
positions: dict[PortID, tuple[int, int]] = {}
input_index = 0
output_index = 0
for port_id, port in ports.items():
if port.direction is SignalDirection.INPUT:
default = (left, top + input_index * 16)
input_index += 1
else:
default = (right, top + output_index * 16)
output_index += 1
position = self._icon.port_positions.get(port_id, default)
positions[port_id] = (max(left, min(right, position[0])), max(top, min(bottom, position[1])))
self._icon.port_positions = positions
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)
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)
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
self.ui.graphicsView.viewport().setCursor(cursor)
def _update_undo_text(self, text: str) -> None:
self.ui.actionUndo.setText(f"Undo {text}" if text else "Undo")
def _update_redo_text(self, text: str) -> None:
self.ui.actionRedo.setText(f"Redo {text}" if text else "Redo")

View File

@@ -0,0 +1,519 @@
from __future__ import annotations
import math
from copy import deepcopy
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 QGraphicsEllipseItem, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget
from bedit_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Ellipse, Icon, Line, LineType, Rectangle, Shape, ShapeID, Text
ICON_SCENE_SIZE = 512
class ShapeCreationTool(Protocol):
def begin(self, position: QPointF) -> None: ...
def update(self, position: QPointF) -> None: ...
def finish(self, position: QPointF) -> Shape | None: ...
def cancel(self) -> None: ...
class RectangleCreationTool:
def __init__(self, scene: QGraphicsScene, layer: int) -> None:
self.scene = scene
self.layer = layer
self.start: QPointF | None = None
self.preview: QGraphicsRectItem | QGraphicsEllipseItem | None = None
def begin(self, position: QPointF) -> None:
position = self._bounded(position)
self.start = position
self.preview = self.scene.addRect(QRectF(position, position), QPen(Qt.PenStyle.DashLine))
def update(self, position: QPointF) -> None:
if self.preview is not None and self.start is not None:
position = self._bounded(position)
self.preview.setRect(QRectF(self.start, position).normalized())
def finish(self, position: QPointF) -> Shape | None:
if self.start is None:
return None
position = self._bounded(position)
rect = QRectF(self.start, position).normalized()
self.cancel()
if rect.width() < 1 or rect.height() < 1:
return None
return self.create_shape(rect)
def create_shape(self, rect: QRectF) -> Shape:
return Rectangle(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height())
def cancel(self) -> None:
if self.preview is not None:
self.scene.removeItem(self.preview)
self.preview = None
self.start = None
def _bounded(self, position: QPointF) -> QPointF:
rect = self.scene.sceneRect()
x = max(rect.left(), min(rect.right(), round(position.x())))
y = max(rect.top(), min(rect.bottom(), round(position.y())))
return QPointF(x, y)
class TextCreationTool(RectangleCreationTool):
def create_shape(self, rect: QRectF) -> Shape:
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
self.layer = layer
self.start: QPointF | None = None
self.preview: QGraphicsLineItem | None = None
def begin(self, position: QPointF) -> None:
position = self._bounded(position)
self.start = position
self.preview = self.scene.addLine(QLineF(position, position), QPen(Qt.PenStyle.DashLine))
def update(self, position: QPointF) -> None:
if self.preview is not None and self.start is not None:
self.preview.setLine(QLineF(self.start, self._bounded(position)))
def finish(self, position: QPointF) -> Shape | None:
if self.start is None:
return None
start = self.start
end = self._bounded(position)
self.cancel()
if start == end:
return None
return Line(layer=self.layer, pos=(round(start.x()), round(start.y())), end=(round(end.x()), round(end.y())))
def cancel(self) -> None:
if self.preview is not None:
self.scene.removeItem(self.preview)
self.preview = None
self.start = None
def _bounded(self, position: QPointF) -> QPointF:
rect = self.scene.sceneRect()
x = max(rect.left(), min(rect.right(), round(position.x())))
y = max(rect.top(), min(rect.bottom(), round(position.y())))
return QPointF(x, y)
class ShapeGraphicsItem(QGraphicsPathItem):
handle_size = 8.0
handle_hit_size = 16.0
def __init__(self, shape_id: ShapeID, shape: Shape, scene: IconGraphicsScene) -> None:
super().__init__()
self.shape_id = shape_id
self.shape_model = deepcopy(shape)
self.icon_scene = scene
self._original_shape: Shape | None = None
self._resize_handle: str | None = None
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def resize_handle_rect(self) -> QRectF:
size = self.handle_size
corner = self.path().boundingRect().bottomRight()
return QRectF(corner.x() - size / 2, corner.y() - size / 2, size, size)
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_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_handle_hit_rects().items() if self.isSelected() and rect.contains(event.pos())), None)
if self._resize_handle is not None:
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._resize_handle is not None:
self.resize_to(event.scenePos(), self._resize_handle)
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._resize_handle is not None:
self.resize_to(event.scenePos(), self._resize_handle)
self._resize_handle = None
event.accept()
else:
super().mouseReleaseEvent(event)
current = self.current_shape()
if self._original_shape is not None and current != self._original_shape:
self.icon_scene.shape_changed.emit(self.shape_id, self._original_shape, current)
self._original_shape = None
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
if not self.isSelected():
self.icon_scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options = menu.addAction("Shape Options")
if menu.exec(event.screenPos()) is options:
self.icon_scene.shape_options_requested.emit(self.shape_id)
event.accept()
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None:
position = value
if isinstance(position, QPointF):
bounds = self.icon_scene.sceneRect()
shape_rect = self.path().boundingRect()
x = max(bounds.left() - shape_rect.left(), min(bounds.right() - shape_rect.right(), round(position.x())))
y = max(bounds.top() - shape_rect.top(), min(bounds.bottom() - shape_rect.bottom(), round(position.y())))
return QPointF(x, y)
return super().itemChange(change, value)
def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None:
super().paint(painter, option, widget)
if self.isSelected():
painter.setPen(QPen(QColor("#ffffff")))
painter.setBrush(QBrush(QColor("#2675bf")))
for rect in self.resize_handles().values():
painter.drawRect(rect)
def current_shape(self) -> Shape:
raise NotImplementedError
def resize_to(self, position: QPointF, handle: str) -> None:
raise NotImplementedError
class RectangleGraphicsItem(ShapeGraphicsItem):
def __init__(self, shape_id: ShapeID, shape: Rectangle, scene: IconGraphicsScene) -> None:
super().__init__(shape_id, shape, scene)
self.rectangle = 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) -> Rectangle:
shape = deepcopy(self.rectangle)
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.addRoundedRect(QRectF(0, 0, width, height), self.rectangle.corner_radius, self.rectangle.corner_radius)
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)
self.text = deepcopy(shape)
self.setPos(shape.pos[0], shape.pos[1])
self._set_size(shape.width, shape.height)
self.setPen(QPen(Qt.PenStyle.NoPen))
self.setBrush(QBrush(QColor(0, 0, 0, 0)))
self.setZValue(shape.layer)
def current_shape(self) -> Text:
shape = deepcopy(self.text)
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 paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None:
super().paint(painter, option, widget)
font = QFont()
font.setPixelSize(max(1, round(self.text.size)))
font.setBold(self.text.bold)
font.setItalic(self.text.italic)
painter.setFont(font)
painter.setPen(self.icon_scene._color(self.text.color))
painter.setClipRect(self.path().boundingRect())
painter.drawText(self.path().boundingRect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.text.text)
def _set_size(self, width: float, height: float) -> None:
self.prepareGeometryChange()
path = QPainterPath()
path.addRect(QRectF(0, 0, width, height))
self.setPath(path)
class LineGraphicsItem(ShapeGraphicsItem):
def __init__(self, shape_id: ShapeID, shape: Line, scene: IconGraphicsScene) -> None:
super().__init__(shape_id, shape, scene)
self.line = deepcopy(shape)
self._set_points(QPointF(shape.pos[0], shape.pos[1]), QPointF(shape.end[0], shape.end[1]))
self.setPen(scene._line_pen(shape.line_type, shape.line_thickness, shape.line_color))
self.setZValue(shape.layer)
def resize_handles(self) -> dict[str, QRectF]:
size = self.handle_size
start = self.path().pointAtPercent(0)
end = self.path().pointAtPercent(1)
return {"start": QRectF(start.x() - size / 2, start.y() - size / 2, size, size), "end": QRectF(end.x() - size / 2, end.y() - size / 2, size, size)}
def current_shape(self) -> Line:
shape = deepcopy(self.line)
start = self.mapToScene(self.path().pointAtPercent(0))
end = self.mapToScene(self.path().pointAtPercent(1))
shape.pos = (round(start.x()), round(start.y()))
shape.end = (round(end.x()), round(end.y()))
return shape
def resize_to(self, position: QPointF, handle: str) -> None:
bounds = self.icon_scene.sceneRect()
position = QPointF(max(bounds.left(), min(bounds.right(), round(position.x()))), max(bounds.top(), min(bounds.bottom(), round(position.y()))))
start = self.mapToScene(self.path().pointAtPercent(0))
end = self.mapToScene(self.path().pointAtPercent(1))
start = position if handle == "start" else start
end = position if handle == "end" else end
self.setPos(0, 0)
self._set_points(start, end)
def _set_points(self, start: QPointF, end: QPointF) -> None:
self.prepareGeometryChange()
path = QPainterPath(start)
path.lineTo(end)
self.setPath(path)
class PortGraphicsItem(QGraphicsRectItem):
size = 16.0
def __init__(self, port_id: PortID, port: Port, position: tuple[int, int], scene: IconGraphicsScene) -> None:
super().__init__(0, 0, self.size, self.size)
self.port_id = port_id
self.icon_scene = scene
self._original_position: tuple[int, int] | None = None
self.setPos(position[0], position[1])
self.setPen(QPen(QColor("#000000")))
self.setBrush(QBrush(QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff")))
self.setZValue(1000000)
self.setToolTip(port.name)
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._original_position = self.position()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
super().mouseReleaseEvent(event)
position = self.position()
if self._original_position is not None and position != self._original_position:
self.icon_scene.port_moved.emit(self.port_id, self._original_position, position)
self._original_position = None
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None:
position = value
if isinstance(position, QPointF):
bounds = self.icon_scene.sceneRect()
x = max(bounds.left(), min(bounds.right() - self.size, round(position.x())))
y = max(bounds.top(), min(bounds.bottom() - self.size, round(position.y())))
return QPointF(x, y)
return super().itemChange(change, value)
def position(self) -> tuple[int, int]:
return (round(self.pos().x()), round(self.pos().y()))
class IconGraphicsScene(QGraphicsScene):
port_moved = Signal(object, object, object)
shape_created = Signal(object)
shape_changed = Signal(object, object, object)
shape_options_requested = Signal(object)
tool_active_changed = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._tool: ShapeCreationTool | None = None
self._ports: dict[PortID, Port] = {}
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)
def set_icon(self, icon: Icon) -> None:
self.cancel_creation_tool()
self.clear()
for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer):
self._add_shape_item(shape_id, shape)
for port_id, port in self._ports.items():
position = icon.port_positions.get(port_id)
if position is not None:
self.addItem(PortGraphicsItem(port_id, port, position, self))
def set_creation_tool(self, tool: ShapeCreationTool) -> None:
self.cancel_creation_tool()
self._tool = tool
self.tool_active_changed.emit(True)
def cancel_creation_tool(self) -> None:
if self._tool is None:
return
self._tool.cancel()
self._tool = None
self.tool_active_changed.emit(False)
def has_creation_tool(self) -> bool:
return self._tool is not None
def selected_shape_ids(self) -> list[ShapeID]:
return [item.shape_id for item in self.selectedItems() if isinstance(item, ShapeGraphicsItem)]
def drawBackground(self, painter: QPainter, rect: QRectF) -> None:
super().drawBackground(painter, rect)
rect = rect.intersected(self.sceneRect())
if rect.isEmpty():
return
pen = QPen(QColor("#d0d0d0"))
pen.setCosmetic(True)
painter.setPen(pen)
first_x = math.floor(rect.left() / 8) * 8
last_x = math.ceil(rect.right() / 8) * 8
first_y = math.floor(rect.top() / 8) * 8
last_y = math.ceil(rect.bottom() / 8) * 8
for x in range(first_x, last_x + 1, 8):
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
for y in range(first_y, last_y + 1, 8):
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._tool is not None and event.button() == Qt.MouseButton.LeftButton:
self._tool.begin(event.scenePos())
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._tool is not None and event.buttons() & Qt.MouseButton.LeftButton:
self._tool.update(event.scenePos())
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._tool is not None and event.button() == Qt.MouseButton.LeftButton:
tool = self._tool
shape = tool.finish(event.scenePos())
self._tool = None
self.tool_active_changed.emit(False)
if shape is not None:
self.shape_created.emit(shape)
event.accept()
return
super().mouseReleaseEvent(event)
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 | Ellipse) -> QPen:
return IconGraphicsScene._line_pen(shape.line_type, shape.line_thickness, shape.line_color)
@staticmethod
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:
return QPen(Qt.PenStyle.NoPen)
return QPen(IconGraphicsScene._color(color), thickness, styles[line_type])
@staticmethod
def _color(value: str) -> QColor:
color = value.removeprefix("#")
if len(color) == 8:
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
return QColor(value)

View File

@@ -1,16 +1,27 @@
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMainWindow, QTabWidget from PySide6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout
from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow
from bedit_gui.views.equation_editor_widget import EquationEditorWidget
from bedit_gui.views.graph_editor_widget import GraphEditorWidget
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self) -> None: def __init__(self, snap_to_grid_size: int = 4) -> None:
super().__init__() super().__init__()
self.ui = Ui_MainWindow() self.ui = Ui_MainWindow()
self.ui.setupUi(self) self.ui.setupUi(self)
central_layout = QVBoxLayout(self.ui.centralwidget)
central_layout.setContentsMargins(0, 0, 0, 0)
self.equation_editor = EquationEditorWidget(self.ui.centralwidget)
self.equation_editor.hide()
central_layout.addWidget(self.equation_editor)
self.graph_editor = GraphEditorWidget(self.ui.centralwidget, snap_to_grid_size)
self.graph_editor.hide()
central_layout.addWidget(self.graph_editor)
self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North)
self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea) self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea)
self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea) self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea)

View File

@@ -2,49 +2,65 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
from PySide6.QtGui import QIcon
from bedit_core.models import Document as CoreDocument, ComponentID, Component, GraphImplementation from bedit_core.models import Component, ComponentID, GraphImplementation
from bedit_core.models import Document as CoreDocument
@dataclass @dataclass
class DocumentTreeNode: class DocumentTreeNode:
name: str name: str
value: object value: object
component_id: ComponentID | None
parent: DocumentTreeNode | None parent: DocumentTreeNode | None
children: list[DocumentTreeNode] children: list[DocumentTreeNode]
class DocumentTreeModel(QAbstractItemModel): class DocumentTreeModel(QAbstractItemModel):
def __init__(self) -> None: rename_document_requested = Signal(str)
rename_component_requested = Signal(Component, str)
def __init__(self, editable: bool = True) -> None:
super().__init__() super().__init__()
self._document: CoreDocument | None = None self._editable = editable
self._root = DocumentTreeNode("Document", None, None, []) 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: def set_document(self, document: CoreDocument) -> None:
self.set_documents([document])
def set_documents(self, documents: list[CoreDocument]) -> None:
self.beginResetModel() self.beginResetModel()
self._document = document self._component_icons = {}
self._root = self._build_tree(document) self._component_nodes = {}
self._root = self._build_tree(documents)
self.endResetModel() self.endResetModel()
def set_component_icon(self, component_id: ComponentID, icon: QIcon) -> None:
node = self._component_nodes.get(component_id)
if node is None or node.parent is None:
return
self._component_icons[component_id] = icon
row = node.parent.children.index(node)
index = self.createIndex(row, 1, node)
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DecorationRole])
def rowCount(self, parent: QModelIndex | None = None) -> int: def rowCount(self, parent: QModelIndex | None = None) -> int:
if parent is not None and parent.isValid() and parent.column() != 0:
return 0
return len(self._node(parent).children) return len(self._node(parent).children)
def columnCount(self, _parent: QModelIndex | None = None) -> int: def columnCount(self, _parent: QModelIndex | None = None) -> int:
return 1 return 2
def index( def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex:
self, if parent is not None and parent.isValid() and parent.column() != 0:
row: int, return QModelIndex()
column: int,
parent: QModelIndex | None = None,
) -> QModelIndex:
parent_node = self._node(parent) parent_node = self._node(parent)
if ( if column not in (0,1) or row < 0 or row >= len(parent_node.children):
column != 0
or row < 0
or row >= len(parent_node.children)
):
return QModelIndex() return QModelIndex()
return self.createIndex(row, column, parent_node.children[row]) return self.createIndex(row, column, parent_node.children[row])
@@ -66,22 +82,44 @@ class DocumentTreeModel(QAbstractItemModel):
row = grandparent.children.index(parent_node) row = grandparent.children.index(parent_node)
return self.createIndex(row, 0, parent_node) return self.createIndex(row, 0, parent_node)
def data( def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> object | None:
self,
index: QModelIndex,
role: int = Qt.ItemDataRole.DisplayRole,
) -> object | None:
if not index.isValid(): if not index.isValid():
return None return None
node = index.internalPointer() node = index.internalPointer()
if ( if not isinstance(node, DocumentTreeNode):
role == Qt.ItemDataRole.DisplayRole return None
and isinstance(node, DocumentTreeNode)
): if index.column() == 0:
return node.name if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
return node.name
elif index.column() == 1:
if role == Qt.ItemDataRole.DecorationRole:
return self._component_icons.get(node.component_id)
if role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignCenter
return None return None
def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool:
if role != Qt.ItemDataRole.EditRole or not index.isValid() or index.column() != 0:
return False
node = index.internalPointer()
if not isinstance(node, DocumentTreeNode) or not isinstance(node.value, (CoreDocument, Component)):
return False
name = str(value).strip()
if not name or name == node.name:
return False
if isinstance(node.value, CoreDocument):
self.rename_document_requested.emit(name)
elif isinstance(node.value, Component):
self.rename_component_requested.emit(node.value, name)
return True
def _node(self, index: QModelIndex | None) -> DocumentTreeNode: def _node(self, index: QModelIndex | None) -> DocumentTreeNode:
if index is None or not index.isValid(): if index is None or not index.isValid():
return self._root return self._root
@@ -89,35 +127,53 @@ class DocumentTreeModel(QAbstractItemModel):
node = index.internalPointer() node = index.internalPointer()
return node if isinstance(node, DocumentTreeNode) else self._root return node if isinstance(node, DocumentTreeNode) else self._root
def _build_tree(self, document: CoreDocument) -> DocumentTreeNode: def value(self, index: QModelIndex) -> object | None:
if not index.isValid():
return None
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)
if not index.isValid():
return flags
node = index.internalPointer()
# Make the document root node editable
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, documents: list[CoreDocument]) -> DocumentTreeNode:
# QT's invisible root # QT's invisible root
root = DocumentTreeNode( root = DocumentTreeNode(
name="", name="",
value=None, value=None,
component_id=None,
parent=None, parent=None,
children=[], children=[],
) )
# Add itself as a child so the document root is visible in the tree def _list_children(parent: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
document_root = DocumentTreeNode( for component_id, component in sorted(components.items(), key=lambda item: (item[1].name.casefold(), item[1].name, str(item[0]))):
name = document.name, component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=parent, children=[])
value = document, parent.children.append(component_node)
parent = root, self._component_nodes[component_id] = component_node
children = []
)
root.children.append(document_root)
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items():
component_node = DocumentTreeNode(
name = component.name,
value = (component_id, component),
parent = root,
children = []
)
root.children.append(component_node)
if isinstance(component.implementation, GraphImplementation): if isinstance(component.implementation, GraphImplementation):
_list_children(component_node, component.implementation.graph.components) _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 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

@@ -0,0 +1,134 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtCore import QItemSelection, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import QWidget
from bedit_core.models import Parameter, ParameterID, ValueType
from bedit_gui.ui.generated.ui_param_editor_widget import Ui_ParamEditor
class ParamEditorWidget(QWidget):
"""Reusable editor for a detached draft of a component's parameters."""
params_changed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.ui = Ui_ParamEditor()
self.ui.setupUi(self)
self._params: dict[ParameterID, Parameter] = {}
self._param_ids: list[ParameterID] = []
self._loading = False
self.ui.typeComboBox.addItem("Real", ValueType.REAL)
self.ui.typeComboBox.addItem("Integer", ValueType.INT)
self.ui.typeComboBox.addItem("Boolean", ValueType.BOOL)
self._list_model = QStandardItemModel(self)
self.ui.portList.setModel(self._list_model)
self.ui.portList.selectionModel().selectionChanged.connect(self._selection_changed)
self.ui.addParam.clicked.connect(self._add_param)
self.ui.removeParam.clicked.connect(self._remove_param)
self.ui.nameEdit.textEdited.connect(self._form_changed)
self.ui.typeComboBox.currentIndexChanged.connect(self._form_changed)
self.ui.valueEdit.textEdited.connect(self._form_changed)
self.ui.widthSize.valueChanged.connect(self._form_changed)
self.ui.heightSize.valueChanged.connect(self._form_changed)
self.ui.descriptionEdit.textChanged.connect(self._form_changed)
self._set_editor_enabled(False)
def set_params(self, params: dict[ParameterID, Parameter]) -> None:
self._params = deepcopy(params)
self._param_ids = list(self._params)
self._rebuild_list()
def params(self) -> dict[ParameterID, Parameter]:
return deepcopy(self._params)
def _rebuild_list(self, selected_id: ParameterID | None = None) -> None:
self._list_model.clear()
for param_id in self._param_ids:
self._list_model.appendRow(QStandardItem(self._params[param_id].name))
if self._param_ids:
if selected_id not in self._params:
selected_id = self._param_ids[0]
index = self._list_model.index(self._param_ids.index(selected_id), 0)
self.ui.portList.setCurrentIndex(index)
else:
self._set_editor_enabled(False)
self.ui.removeParam.setEnabled(False)
def _selection_changed(self, _selected: QItemSelection, _deselected: QItemSelection) -> None:
param_id = self._selected_param_id()
self.ui.removeParam.setEnabled(param_id is not None)
self._set_editor_enabled(param_id is not None)
if param_id is not None:
self._load_param(self._params[param_id])
def _selected_param_id(self) -> ParameterID | None:
index = self.ui.portList.currentIndex()
if not index.isValid() or index.row() >= len(self._param_ids):
return None
return self._param_ids[index.row()]
def _load_param(self, param: Parameter) -> None:
self._loading = True
self.ui.nameEdit.setText(param.name)
self.ui.typeComboBox.setCurrentIndex(self.ui.typeComboBox.findData(param.value_type))
self.ui.valueEdit.setText(param.value)
self.ui.widthSize.setValue(param.matrix_size[0])
self.ui.heightSize.setValue(param.matrix_size[1])
self.ui.descriptionEdit.setPlainText(param.description or "")
self._loading = False
def _form_changed(self, *_args: object) -> None:
if self._loading:
return
param_id = self._selected_param_id()
if param_id is None:
return
old_param = self._params[param_id]
self._params[param_id] = Parameter(
name=self.ui.nameEdit.text().strip(),
value_type=self.ui.typeComboBox.currentData(),
value=self.ui.valueEdit.text().strip(),
matrix_size=[self.ui.widthSize.value(), self.ui.heightSize.value()],
quantity=old_param.quantity,
unit=old_param.unit,
description=self.ui.descriptionEdit.toPlainText() or None,
)
self._list_model.item(self._param_ids.index(param_id)).setText(self._params[param_id].name)
self.params_changed.emit()
def _add_param(self) -> None:
param_id = ParameterID()
param = Parameter(name=f"Parameter {len(self._params) + 1}")
self._params[param_id] = param
self._param_ids.append(param_id)
self._rebuild_list(param_id)
self.params_changed.emit()
def _remove_param(self) -> None:
param_id = self._selected_param_id()
if param_id is None:
return
row = self._param_ids.index(param_id)
del self._params[param_id]
self._param_ids.remove(param_id)
selected = self._param_ids[min(row, len(self._param_ids) - 1)] if self._param_ids else None
self._rebuild_list(selected)
self.params_changed.emit()
def _set_editor_enabled(self, enabled: bool) -> None:
for widget in (self.ui.nameEdit, self.ui.typeComboBox, self.ui.widthSize, self.ui.heightSize, self.ui.descriptionEdit):
widget.setEnabled(enabled)

View File

@@ -0,0 +1,240 @@
from __future__ import annotations
from copy import deepcopy
from PySide6.QtCore import QItemSelection, Signal
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
class PortEditorWidget(QWidget):
"""Reusable editor for a detached draft of an interface's ports."""
ports_changed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
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
self._type_group = QButtonGroup(self)
self._type_group.addButton(self.ui.typeSignal)
self._type_group.addButton(self.ui.typeBond)
self._orientation_group = QButtonGroup(self)
self._orientation_group.addButton(self.ui.inputOrientation)
self._orientation_group.addButton(self.ui.outputOrientation)
self.ui.signalTypeComboBox.addItem("Real", ValueType.REAL)
self.ui.signalTypeComboBox.addItem("Integer", ValueType.INT)
self.ui.signalTypeComboBox.addItem("Boolean", ValueType.BOOL)
self.ui.domainComboBox.setEditable(True)
for causality in PortCausality:
self.ui.causalityComboBox.addItem(causality.value.replace("_", " ").title(), causality)
self._list_model = QStandardItemModel(self)
self.ui.portList.setModel(self._list_model)
self.ui.portList.selectionModel().selectionChanged.connect(
self._selection_changed
)
self.ui.addPort.clicked.connect(self._add_port)
self.ui.removePort.clicked.connect(self._remove_port)
self.ui.nameEdit.textEdited.connect(self._form_changed)
self.ui.typeSignal.toggled.connect(self._form_changed)
self.ui.typeBond.toggled.connect(self._form_changed)
self.ui.inputOrientation.toggled.connect(self._form_changed)
self.ui.outputOrientation.toggled.connect(self._form_changed)
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)
self.ui.descriptionEdit.textChanged.connect(self._form_changed)
self._set_editor_enabled(False)
self._update_option_visibility()
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:
self._list_model.appendRow(QStandardItem(self._ports[port_id].name))
if self._port_ids:
if selected_id not in self._ports:
selected_id = self._port_ids[0]
row = self._port_ids.index(selected_id)
index = self._list_model.index(row, 0)
self.ui.portList.setCurrentIndex(index)
else:
self._set_editor_enabled(False)
self.ui.removePort.setEnabled(False)
def _selection_changed(self,_selected: QItemSelection,_deselected: QItemSelection) -> None:
port_id = self._selected_port_id()
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._port_metadata.get(port_id, PortMetadata()))
def _selected_port_id(self) -> PortID | None:
index = self.ui.portList.currentIndex()
if not index.isValid() or index.row() >= len(self._port_ids):
return None
return self._port_ids[index.row()]
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)
self.ui.outputOrientation.setChecked(port.direction is SignalDirection.OUTPUT)
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):
self.ui.typeSignal.setChecked(True)
self.ui.signalTypeComboBox.setCurrentIndex(
self.ui.signalTypeComboBox.findData(port.value_type)
)
else:
self.ui.typeBond.setChecked(True)
assert isinstance(port, BondPort)
if self.ui.domainComboBox.findText(port.domain) < 0:
self.ui.domainComboBox.addItem(port.domain)
self.ui.domainComboBox.setCurrentText(port.domain)
self.ui.causalityComboBox.setCurrentIndex(self.ui.causalityComboBox.findData(port.causality_preference))
self._loading = False
self._update_option_visibility()
def _form_changed(self, *_args: object) -> None:
if self._loading:
return
self._update_option_visibility()
port_id = self._selected_port_id()
if port_id is None:
return
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
)
self.ports_changed.emit()
def _port_from_form(self, old_port: Port) -> Port:
common = {
"name": self.ui.nameEdit.text().strip(),
"direction": (
SignalDirection.INPUT
if self.ui.inputOrientation.isChecked()
else SignalDirection.OUTPUT
),
"multiplicity": self.ui.multiplicityCheckBox.isChecked(),
"matrix_size": [
self.ui.widthSize.value(),
self.ui.heightSize.value(),
],
"description": self.ui.descriptionEdit.toPlainText() or None,
}
if self.ui.typeSignal.isChecked():
return SignalPort(
**common,
value_type=self.ui.signalTypeComboBox.currentData(),
quantity=(
old_port.quantity if isinstance(old_port, SignalPort) else None
),
unit=old_port.unit if isinstance(old_port, SignalPort) else None,
)
return BondPort(
**common,
domain=self.ui.domainComboBox.currentText(),
causality_preference=self.ui.causalityComboBox.currentData(),
)
def _add_port(self) -> None:
port_id = PortID()
number = len(self._ports) + 1
port = SignalPort(
name=f"Port {number}",
direction=SignalDirection.INPUT,
)
self._ports[port_id] = port
self._port_ids.append(port_id)
self._rebuild_list(port_id)
self.ports_changed.emit()
def _remove_port(self) -> None:
port_id = self._selected_port_id()
if port_id is None:
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)]
if self._port_ids
else None
)
self._rebuild_list(selected)
self.ports_changed.emit()
def _update_option_visibility(self) -> None:
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:
for index in range(layout.count()):
widget = layout.itemAt(index).widget()
if widget is not None:
widget.setVisible(visible)
def _set_editor_enabled(self, enabled: bool) -> None:
for layout in (
self.ui.basicForm,
self.ui.signalOptions,
self.ui.bondOptions,
self.ui.descriptionForm,
):
for index in range(layout.count()):
widget = layout.itemAt(index).widget()
if widget is not None:
widget.setEnabled(enabled)

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
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 Ellipse, Line, LineType, Rectangle, Shape, Text
from bedit_gui.views.color_button import ColorButton
class ShapeOptionsDialog(QDialog):
def __init__(self, shape: Shape, scene_rect: QRectF, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._shape = deepcopy(shape)
self._scene_rect = scene_rect
self.setWindowTitle("Shape Options")
self.form = QFormLayout()
self.type_label = QLabel(shape.type or "")
self.layer = QSpinBox()
self.layer.setRange(-1000000, 1000000)
self.layer.setValue(shape.layer)
self.x = QSpinBox()
self.x.setRange(round(scene_rect.left()), round(scene_rect.right() - 1))
self.x.setValue(shape.pos[0])
self.y = QSpinBox()
self.y.setRange(round(scene_rect.top()), round(scene_rect.bottom() - 1))
self.y.setValue(shape.pos[1])
self.form.addRow("Type", self.type_label)
self.form.addRow("Layer", self.layer)
self.form.addRow("X", self.x)
self.form.addRow("Y", self.y)
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):
self._add_line_fields(shape)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(self.form)
layout.addWidget(buttons)
def shape(self) -> Shape:
if isinstance(self._shape, Rectangle):
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())
return Text(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, color=self.color.color(), bold=self.bold.isChecked(), italic=self.italic.isChecked(), size=self.size.value(), text=self.text.text())
if isinstance(self._shape, Line):
return Line(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), end=(self.end_x.value(), self.end_y.value()), line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), line_color=self.line_color.color())
shape = deepcopy(self._shape)
shape.layer = self.layer.value()
shape.pos = (self.x.value(), self.y.value())
return shape
def _add_rectangle_fields(self, shape: Rectangle) -> 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.corner_radius = QDoubleSpinBox()
self.corner_radius.setRange(0, max(self._scene_rect.width(), self._scene_rect.height()))
self.corner_radius.setValue(shape.corner_radius)
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("Corner radius", self.corner_radius)
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()))
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.color = ColorButton(shape.color)
self.bold = QCheckBox()
self.bold.setChecked(shape.bold)
self.italic = QCheckBox()
self.italic.setChecked(shape.italic)
self.size = QDoubleSpinBox()
self.size.setRange(1, 1000)
self.size.setValue(shape.size)
self.text = QLineEdit(shape.text)
self.form.addRow("Width", self.width)
self.form.addRow("Height", self.height)
self.form.addRow("Color", self.color)
self.form.addRow("Bold", self.bold)
self.form.addRow("Italic", self.italic)
self.form.addRow("Size", self.size)
self.form.addRow("Text", self.text)
def _add_line_fields(self, shape: Line) -> None:
self.x.setMaximum(round(self._scene_rect.right()))
self.y.setMaximum(round(self._scene_rect.bottom()))
self.end_x = QSpinBox()
self.end_x.setRange(round(self._scene_rect.left()), round(self._scene_rect.right()))
self.end_x.setValue(shape.end[0])
self.end_y = QSpinBox()
self.end_y.setRange(round(self._scene_rect.top()), round(self._scene_rect.bottom()))
self.end_y.setValue(shape.end[1])
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.form.addRow("End X", self.end_x)
self.form.addRow("End Y", self.end_y)
self.form.addRow("Line type", self.line_type)
self.form.addRow("Line thickness", self.line_thickness)
self.form.addRow("Line color", self.line_color)

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from bedit_gui.simulation_models import SimulationPlotSettings
from bedit_simulation import SimulationResult
class SimulationPlotWidget(QWidget):
settings_requested = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.figure = Figure(layout="constrained")
self.canvas = FigureCanvasQTAgg(self.figure)
self.toolbar = NavigationToolbar2QT(self.canvas, self)
for action in self.toolbar.actions():
if action.text() in ("Customize", "Subplots"):
self.toolbar.removeAction(action)
self.toolbar.addSeparator()
self.settings_action = self.toolbar.addAction("Plot settings…")
self.settings_action.setToolTip("Edit plot title, axes, traces, grid, and legend")
self.settings_action.triggered.connect(self.settings_requested)
self.axes = self.figure.add_subplot()
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
def set_plot(self, results: list[SimulationResult], signals: list[str], x_axis: str | None = None, settings: SimulationPlotSettings | None = None) -> None:
settings = settings or SimulationPlotSettings()
self.axes.clear()
for signal in signals:
trace = settings.traces.get(signal)
if trace is not None and not trace.visible:
continue
x_values: list[float] = []
values: list[float] = []
sample_offset = 0
for result in results:
result_values = result.data.get(signal)
if result_values is None:
continue
result_x = result.data.get(x_axis or "time")
if result_x is not None and len(result_x) == len(result_values):
x_values.extend(result_x)
elif x_axis is not None:
continue
else:
x_values.extend(range(sample_offset, sample_offset + len(result_values)))
values.extend(result_values)
sample_offset += len(result_values)
if values:
options = {"label": trace.label or signal, "linestyle": trace.line_style, "linewidth": trace.line_width, "marker": trace.marker or None, "markersize": trace.marker_size} if trace is not None else {"label": signal}
if trace is not None and trace.color:
options["color"] = trace.color
self.axes.plot(x_values, values, **options)
self.axes.set_title(settings.title)
self.axes.set_xlabel(settings.x_label or x_axis or "Time")
self.axes.set_ylabel(settings.y_label)
self.axes.set_xscale(settings.x_scale)
self.axes.set_yscale(settings.y_scale)
if not settings.x_auto:
self.axes.set_xlim(settings.x_min, settings.x_max)
if not settings.y_auto:
self.axes.set_ylim(settings.y_min, settings.y_max)
self.axes.grid(settings.grid_visible, axis=settings.grid_axis, linestyle=settings.grid_style, alpha=settings.grid_alpha)
if self.axes.lines and settings.legend_visible:
self.axes.legend(loc=settings.legend_location)
self.canvas.draw_idle()

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from PySide6.QtWidgets import QMainWindow
from bedit_gui.ui.generated.ui_simulation_window import Ui_SimulationWindow
class SimulationWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.ui = Ui_SimulationWindow()
self.ui.setupUi(self)
self.setWindowTitle("BEsim")

View File

@@ -4,8 +4,10 @@ from .openmodelica import (
OpenModelicaError, OpenModelicaError,
OpenModelicaRunner, OpenModelicaRunner,
ProcessResult, ProcessResult,
SimulationCancelledError,
) )
from .results import SimulationResult, load_openmodelica_csv from .results import SimulationResult, load_openmodelica_csv
from .compile import compile_component, compile_component_sync
from .simulation import ( from .simulation import (
ModelBuildResult, ModelBuildResult,
ModelCheckResult, ModelCheckResult,
@@ -16,19 +18,25 @@ from .simulation import (
SimulationStateError, SimulationStateError,
simulate, simulate,
) )
from .runtime import SimulationRunSettings, SimulationSession
__all__ = [ __all__ = [
"OpenModelicaError",
"OpenModelicaRunner",
"ProcessResult",
"ModelBuildResult", "ModelBuildResult",
"ModelCheckResult", "ModelCheckResult",
"ModelInfo", "ModelInfo",
"OpenModelicaError",
"OpenModelicaRunner",
"ProcessResult",
"Simulation", "Simulation",
"SimulationCancelledError",
"SimulationOptions", "SimulationOptions",
"SimulationProgress", "SimulationProgress",
"SimulationStateError",
"SimulationResult", "SimulationResult",
"SimulationRunSettings",
"SimulationSession",
"SimulationStateError",
"compile_component",
"compile_component_sync",
"load_openmodelica_csv", "load_openmodelica_csv",
"simulate", "simulate",
] ]

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from bedit_core.models import Component
from .openmodelica import OpenModelicaRunner
from .simulation import ModelBuildResult, Simulation
async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult:
"""Compose and compile a component without creating a GUI application."""
simulation = Simulation(OpenModelicaRunner(omc_command, timeout=timeout))
await simulation.load(component)
return await simulation.compile(working_directory)
def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult:
"""Synchronous entry point for scripts and non-async suite applications."""
return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout))

View File

@@ -4,8 +4,10 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import signal
import shlex import shlex
import subprocess import subprocess
import time
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -22,11 +24,23 @@ class ProcessResult:
stdout: str stdout: str
stderr: 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): class OpenModelicaError(RuntimeError):
"""Raised when OMC cannot start or reports a failure.""" """Raised when OMC cannot start or reports a failure."""
class SimulationCancelledError(RuntimeError):
"""Raised when a running compiled simulation is cancelled."""
ProcessExecutor = Callable[ ProcessExecutor = Callable[
[Sequence[str], Path, float | None, Mapping[str, str] | None], [Sequence[str], Path, float | None, Mapping[str, str] | None],
ProcessResult, ProcessResult,
@@ -74,7 +88,7 @@ class OpenModelicaRunner:
f"OpenModelica timed out after {error.timeout} seconds" f"OpenModelica timed out after {error.timeout} seconds"
) from error ) from error
if result.return_code != 0: if result.return_code != 0:
details = result.stderr.strip() or result.stdout.strip() details = result.diagnostics()
suffix = f": {details}" if details else "" suffix = f": {details}" if details else ""
raise OpenModelicaError( raise OpenModelicaError(
f"OpenModelica exited with status {result.return_code}{suffix}" f"OpenModelica exited with status {result.return_code}{suffix}"
@@ -89,6 +103,58 @@ class OpenModelicaRunner:
"""Run OMC on an asyncio worker thread.""" """Run OMC on an asyncio worker thread."""
return await _run_on_worker(self._run, script, working_directory) return await _run_on_worker(self._run, script, working_directory)
def _run_cancellable(self, script: Path, working_directory: Path, cancel_event: Event) -> ProcessResult:
command = (*self.command, str(script.resolve()))
return self._run_cancellable_process(command, working_directory, cancel_event)
def _run_cancellable_process(self, command: Sequence[str], working_directory: Path, cancel_event: Event) -> ProcessResult:
process_environment = None
if self.environment is not None:
process_environment = {**os.environ, **self.environment}
try:
process = subprocess.Popen(list(command), cwd=working_directory, env=process_environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=os.name != "nt")
except OSError as exc:
raise OpenModelicaError(f"simulation executable could not start: {exc}") from exc
deadline = time.monotonic() + self.timeout if self.timeout is not None else None
while True:
if cancel_event.is_set():
_terminate_process(process)
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
_kill_process(process)
stdout, stderr = process.communicate()
raise SimulationCancelledError("simulation was cancelled")
if deadline is not None and time.monotonic() >= deadline:
_kill_process(process)
process.communicate()
raise OpenModelicaError(f"simulation timed out after {self.timeout} seconds")
try:
stdout, stderr = process.communicate(timeout=0.05)
break
except subprocess.TimeoutExpired:
continue
result = ProcessResult(command=tuple(command), return_code=process.returncode, stdout=stdout, stderr=stderr)
if result.return_code != 0:
details = result.diagnostics()
suffix = f": {details}" if details else ""
raise OpenModelicaError(f"simulation exited with status {result.return_code}{suffix}")
return result
def _terminate_process(process: subprocess.Popen[str]) -> None:
if os.name == "nt":
process.terminate()
else:
os.killpg(process.pid, signal.SIGTERM)
def _kill_process(process: subprocess.Popen[str]) -> None:
if os.name == "nt":
process.kill()
else:
os.killpg(process.pid, signal.SIGKILL)
def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]: def _command_parts(command: str | Sequence[str]) -> tuple[str, ...]:
if isinstance(command, str): if isinstance(command, str):
@@ -140,7 +206,7 @@ async def _run_on_worker(
def invoke() -> None: def invoke() -> None:
try: try:
results.append(operation(*args, **kwargs)) results.append(operation(*args, **kwargs))
except BaseException as error: except Exception as error: # noqa: BLE001 - worker must relay operation failures
errors.append(error) errors.append(error)
finally: finally:
finished.set() finished.set()

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from .openmodelica import OpenModelicaRunner
from .results import SimulationResult
from .simulation import Simulation, SimulationOptions, SimulationStateError
@dataclass(frozen=True)
class SimulationRunSettings:
start_time: float = 0.0
duration: float = 1.0
use_timed_steps: bool = False
number_of_steps: int = 500
step_size: float = 0.002
tolerance: float = 1e-6
method: str | None = None
def __post_init__(self) -> None:
if self.duration <= 0:
raise ValueError("simulation duration must be positive")
if self.number_of_steps <= 0:
raise ValueError("number of steps must be positive")
if self.step_size <= 0:
raise ValueError("step size must be positive")
if self.tolerance <= 0:
raise ValueError("simulation tolerance must be positive")
class SimulationSession:
"""Run consecutive time ranges for one compiled model."""
def __init__(self, model_name: str, executable: str | Path, settings: SimulationRunSettings, *, current_end_time: float | None = None, results: Sequence[SimulationResult] = (), runner: OpenModelicaRunner | None = None) -> None:
self.settings = settings
self.current_end_time = settings.start_time if current_end_time is None else current_end_time
self.results = list(results)
self._simulation = Simulation(runner)
self._simulation.load_compiled(model_name, executable)
@property
def is_running(self) -> bool:
return self._simulation.is_running
def get_progress(self) -> int:
return self._simulation.get_progress()
async def run_next(self) -> SimulationResult:
start_time = self.settings.start_time
stop_time = self.current_end_time + self.settings.duration
run_duration = stop_time - start_time
intervals = math.ceil(run_duration / self.settings.step_size) if self.settings.use_timed_steps else math.ceil(self.settings.number_of_steps * run_duration / self.settings.duration)
options = SimulationOptions(start_time=start_time, stop_time=stop_time, number_of_intervals=intervals, tolerance=self.settings.tolerance, method=self.settings.method)
result = await self._simulation.run(options)
# TODO: Continue from OpenModelica restart state instead of recomputing the full time range.
self.current_end_time = stop_time
self.results[:] = [result]
return result
def cancel(self) -> bool:
return self._simulation.cancel()
def reset(self) -> None:
if self.is_running:
raise SimulationStateError("cannot reset while a simulation is running")
self.current_end_time = self.settings.start_time
self.results.clear()

Some files were not shown because too many files have changed in this diff Show More